refactor(app): migrate main + preload to TypeScript with typed seams (Phase 5d-3c-ii)
Convert the app's main and preload source from .js to .ts (git mv preserves history), adding types where they carry value: the core-consumption seam (BuildIndexOptions/BuildIndexResult, FileInfo), the indexer service/worker factories, and the preload IPC bridge. Module-to-module specifiers use the real .ts extension (mirroring scripts/, since Node type-stripping does not rewrite .js->.ts); the worker's runtime path stays indexer-worker.js because that is the built output. Toolchain: - Add app/tsconfig.json: strict but noImplicitAny:false (the app orchestrates the already-strict core; annotating every SQLite-handle helper is low-value churn) + allowImportingTsExtensions (safe under noEmit). - Add @types/better-sqlite3 for the injected binding. - electron.vite.config.ts inputs -> .ts; refresh the stale CommonJS comment. - typecheck script runs root + app projects. Root tsconfig excludes the app-importing tests (app-*.test.mjs, recap-capture-query.test.mjs) so the lenient app files are not dragged into the strict root program; the app source is covered by app/tsconfig.json instead. See docs/adr/0005. Verified: npm run typecheck (root + app) clean; suite 121/121; electron-vite build emits all 6 main entries + preload with no .ts/node:sqlite residue in the bundles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
60d47a852e
commit
01a390fa10
+10
-10
@@ -2,22 +2,22 @@ import { resolve } from 'node:path';
|
|||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
// Kept CommonJS for the first electron-vite boot (no "type":"module" yet); the
|
// The app main/preload/renderer are TypeScript + ESM. Each main-process module
|
||||||
// TS + ESM migration is a later stage. Each main-process module is its own input
|
// is its own rollup input so it is emitted to out/main/<name>.js and the
|
||||||
// so it is emitted to out/main/<name>.js and the CommonJS require("./x") calls
|
// relative imports between them (and `new Worker(__dirname/indexer-worker.js)`)
|
||||||
// between them (and `new Worker(__dirname/indexer-worker.js)`) resolve at runtime.
|
// resolve to the built .js at runtime.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve('src/main/index.js'),
|
index: resolve('src/main/index.ts'),
|
||||||
indexer: resolve('src/main/indexer.js'),
|
indexer: resolve('src/main/indexer.ts'),
|
||||||
'indexer-service': resolve('src/main/indexer-service.js'),
|
'indexer-service': resolve('src/main/indexer-service.ts'),
|
||||||
'indexer-worker': resolve('src/main/indexer-worker.js'),
|
'indexer-worker': resolve('src/main/indexer-worker.ts'),
|
||||||
'indexer-worker-client': resolve('src/main/indexer-worker-client.js'),
|
'indexer-worker-client': resolve('src/main/indexer-worker-client.ts'),
|
||||||
'recap-capture-query': resolve('src/main/recap-capture-query.js'),
|
'recap-capture-query': resolve('src/main/recap-capture-query.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+11
@@ -12,6 +12,7 @@
|
|||||||
"chokidar": "^4.0.3"
|
"chokidar": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
"electron-builder": "^25.0.0",
|
"electron-builder": "^25.0.0",
|
||||||
@@ -1848,6 +1849,16 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/better-sqlite3": {
|
||||||
|
"version": "7.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cacheable-request": {
|
"node_modules/@types/cacheable-request": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"chokidar": "^4.0.3"
|
"chokidar": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
"electron-builder": "^25.0.0",
|
"electron-builder": "^25.0.0",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import fs from 'node:fs';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import chokidar from 'chokidar';
|
import chokidar from 'chokidar';
|
||||||
import { writeHeartbeat } from './indexer.js';
|
import { writeHeartbeat } from './indexer.ts';
|
||||||
import { createIndexerService } from './indexer-service.js';
|
import { createIndexerService } from './indexer-service.ts';
|
||||||
import { createWorkerBuildIndex } from './indexer-worker-client.js';
|
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
||||||
import { buildRecapExportQuery } from './recap-capture-query.js';
|
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ function migrateLegacyDbIfNeeded(paths = getPathsForClaudeDir()) {
|
|||||||
fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true });
|
fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true });
|
||||||
fs.copyFileSync(legacyDbPath, paths.dbPath);
|
fs.copyFileSync(legacyDbPath, paths.dbPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn?.(`Obelisk legacy DB migration skipped: ${error.message}`);
|
console.warn?.(`Obelisk legacy DB migration skipped: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ function resolveSchemaPath() {
|
|||||||
path.join(__dirname, 'schema.sql'),
|
path.join(__dirname, 'schema.sql'),
|
||||||
path.join(__dirname, '..', 'scripts', 'schema.sql'),
|
path.join(__dirname, '..', 'scripts', 'schema.sql'),
|
||||||
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
|
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
|
||||||
].filter(Boolean);
|
].filter((c): c is string => Boolean(c));
|
||||||
return candidates.find(p => fs.existsSync(p));
|
return candidates.find(p => fs.existsSync(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ function openDb(dbPath = getPathsForClaudeDir().dbPath) {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyIndexUpdated(result = {}) {
|
function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) {
|
||||||
const affectedSessionIds = Array.isArray(result.affectedSessionIds)
|
const affectedSessionIds = Array.isArray(result.affectedSessionIds)
|
||||||
? [...new Set(result.affectedSessionIds.filter(Boolean))]
|
? [...new Set(result.affectedSessionIds.filter(Boolean))]
|
||||||
: [];
|
: [];
|
||||||
@@ -173,7 +173,7 @@ function notifyIndexUpdated(result = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceWhereClause(opts = {}, column = 'source') {
|
function sourceWhereClause(opts: { includeCodex?: boolean; source?: string } = {}, column = "source"): { sql: string; params: unknown[] } {
|
||||||
if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] };
|
if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] };
|
||||||
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
|
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
|
||||||
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
|
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
|
||||||
@@ -283,7 +283,7 @@ function createWindow() {
|
|||||||
|
|
||||||
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
|
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
|
||||||
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
|
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
|
||||||
let obeliskWatcher = null;
|
let obeliskWatcher: import("chokidar").FSWatcher | null = null;
|
||||||
|
|
||||||
function startObeliskWatcher() {
|
function startObeliskWatcher() {
|
||||||
if (obeliskWatcher) return obeliskWatcher;
|
if (obeliskWatcher) return obeliskWatcher;
|
||||||
@@ -340,7 +340,7 @@ ipcMain.handle('db:getSessions', (_, opts = {}) => {
|
|||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
const { project, limit = 200 } = opts;
|
const { project, limit = 200 } = opts;
|
||||||
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`;
|
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`;
|
||||||
const params = [];
|
const params: unknown[] = [];
|
||||||
const sourceFilter = sourceWhereClause(opts);
|
const sourceFilter = sourceWhereClause(opts);
|
||||||
if (sourceFilter.sql) {
|
if (sourceFilter.sql) {
|
||||||
sql = appendWhere(sql, params, sourceFilter.sql);
|
sql = appendWhere(sql, params, sourceFilter.sql);
|
||||||
@@ -437,7 +437,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
|||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const rawThreadId = match[1];
|
const rawThreadId = match[1];
|
||||||
const targetLine = Number(match[2]);
|
const targetLine = Number(match[2]);
|
||||||
let jsonlPath = null;
|
let jsonlPath: string | null = null;
|
||||||
if (!msg.agent_id) {
|
if (!msg.agent_id) {
|
||||||
jsonlPath = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id)?.jsonl_path || null;
|
jsonlPath = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id)?.jsonl_path || null;
|
||||||
}
|
}
|
||||||
@@ -469,7 +469,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve JSONL path
|
// Resolve JSONL path
|
||||||
let jsonlPath = null;
|
let jsonlPath: string | null = null;
|
||||||
if (msg.agent_id) {
|
if (msg.agent_id) {
|
||||||
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
||||||
if (wa) {
|
if (wa) {
|
||||||
@@ -501,7 +501,7 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
|||||||
const content = obj.message?.content;
|
const content = obj.message?.content;
|
||||||
if (typeof content === 'string') return content;
|
if (typeof content === 'string') return content;
|
||||||
if (!Array.isArray(content)) return null;
|
if (!Array.isArray(content)) return null;
|
||||||
const parts = [];
|
const parts: string[] = [];
|
||||||
for (const b of content) {
|
for (const b of content) {
|
||||||
if (b.type === 'text' && b.text) parts.push(b.text);
|
if (b.type === 'text' && b.text) parts.push(b.text);
|
||||||
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
||||||
@@ -614,7 +614,7 @@ async function createExportCapture(parentWin, query) {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
offscreen: true,
|
offscreen: true,
|
||||||
deviceScaleFactor: 2,
|
deviceScaleFactor: 2,
|
||||||
},
|
} as Electron.WebPreferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
||||||
@@ -802,6 +802,7 @@ ipcMain.handle('settings:set', async (_, key, value) => {
|
|||||||
|
|
||||||
ipcMain.handle('settings:browseFolder', async (event) => {
|
ipcMain.handle('settings:browseFolder', async (event) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!win) return null;
|
||||||
const { filePaths } = await dialog.showOpenDialog(win, {
|
const { filePaths } = await dialog.showOpenDialog(win, {
|
||||||
properties: ['openDirectory'],
|
properties: ['openDirectory'],
|
||||||
title: 'Select Claude Code data folder',
|
title: 'Select Claude Code data folder',
|
||||||
@@ -851,7 +852,7 @@ ipcMain.handle('settings:rebuildIndex', async () => {
|
|||||||
try {
|
try {
|
||||||
openDb(paths.dbPath);
|
openDb(paths.dbPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn?.(`Obelisk DB reopen after rebuild failed: ${error.message}`);
|
console.warn?.(`Obelisk DB reopen after rebuild failed: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
|
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
|
||||||
@@ -9,6 +9,34 @@ const DEFAULT_STABILITY_MS = 500;
|
|||||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||||
const DEFAULT_WATCH_RETRY_MS = 5000;
|
const DEFAULT_WATCH_RETRY_MS = 5000;
|
||||||
|
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
interface Timers {
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => TimerHandle;
|
||||||
|
clearTimeout: (handle: TimerHandle) => void;
|
||||||
|
setInterval?: (fn: () => void, ms?: number) => TimerHandle;
|
||||||
|
clearInterval?: (handle: TimerHandle) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Watcher {
|
||||||
|
close(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndexerServiceOptions {
|
||||||
|
projectsDir?: string;
|
||||||
|
watchDirs?: string | string[];
|
||||||
|
debounceMs?: number;
|
||||||
|
stabilityMs?: number;
|
||||||
|
heartbeatMs?: number;
|
||||||
|
watchRetryMs?: number;
|
||||||
|
buildIndex?: (args: { reason?: string; changedPaths?: string[] }) => unknown;
|
||||||
|
writeHeartbeat?: () => void;
|
||||||
|
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null;
|
||||||
|
chokidar?: any;
|
||||||
|
timers?: Timers;
|
||||||
|
logger?: { warn?: (msg: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
function createIndexerService({
|
function createIndexerService({
|
||||||
projectsDir = DEFAULT_PROJECTS_DIR,
|
projectsDir = DEFAULT_PROJECTS_DIR,
|
||||||
watchDirs = [projectsDir],
|
watchDirs = [projectsDir],
|
||||||
@@ -27,13 +55,13 @@ function createIndexerService({
|
|||||||
clearInterval,
|
clearInterval,
|
||||||
},
|
},
|
||||||
logger = console,
|
logger = console,
|
||||||
} = {}) {
|
}: IndexerServiceOptions = {}) {
|
||||||
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
|
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
|
||||||
const watch = watchProjects || ((onChange) => {
|
const watch = watchProjects || ((onChange) => {
|
||||||
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
|
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
|
||||||
const existingRoots = roots.filter(root => fs.existsSync(root));
|
const existingRoots = roots.filter(root => fs.existsSync(root));
|
||||||
if (!existingRoots.length) return null;
|
if (!existingRoots.length) return null;
|
||||||
const watchers = [];
|
const watchers: any[] = [];
|
||||||
const onFileChange = (filename) => {
|
const onFileChange = (filename) => {
|
||||||
const name = filename ? String(filename) : '';
|
const name = filename ? String(filename) : '';
|
||||||
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
|
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
|
||||||
@@ -57,7 +85,7 @@ function createIndexerService({
|
|||||||
.on('change', onFileChange)
|
.on('change', onFileChange)
|
||||||
.on('unlink', onFileChange)
|
.on('unlink', onFileChange)
|
||||||
.on('error', (error) => {
|
.on('error', (error) => {
|
||||||
logger.warn?.(`Obelisk watcher failed: ${error.message}`);
|
logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`);
|
||||||
});
|
});
|
||||||
watchers.push(watcher);
|
watchers.push(watcher);
|
||||||
}
|
}
|
||||||
@@ -68,19 +96,19 @@ function createIndexerService({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let buildTimer = null;
|
let buildTimer: TimerHandle | null = null;
|
||||||
let stabilityTimer = null;
|
let stabilityTimer: TimerHandle | null = null;
|
||||||
let heartbeatTimer = null;
|
let heartbeatTimer: TimerHandle | null = null;
|
||||||
let watchRetryTimer = null;
|
let watchRetryTimer: TimerHandle | null = null;
|
||||||
let watcher = null;
|
let watcher: Watcher | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let running = false;
|
let running = false;
|
||||||
let pending = false;
|
let pending = false;
|
||||||
let lastReason = null;
|
let lastReason: string | null = null;
|
||||||
let changedPaths = new Set();
|
let changedPaths = new Set<string>();
|
||||||
let idlePromise = Promise.resolve();
|
let idlePromise = Promise.resolve();
|
||||||
|
|
||||||
const addChangedPath = (changedPath) => {
|
const addChangedPath = (changedPath?: string | string[]) => {
|
||||||
if (Array.isArray(changedPath)) {
|
if (Array.isArray(changedPath)) {
|
||||||
for (const p of changedPath) addChangedPath(p);
|
for (const p of changedPath) addChangedPath(p);
|
||||||
return;
|
return;
|
||||||
@@ -96,7 +124,7 @@ function createIndexerService({
|
|||||||
return paths;
|
return paths;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runBuildNow = (reason = 'manual', paths = undefined) => {
|
const runBuildNow = (reason = "manual", paths: string[] | undefined = undefined) => {
|
||||||
addChangedPath(paths);
|
addChangedPath(paths);
|
||||||
if (stopped) return idlePromise;
|
if (stopped) return idlePromise;
|
||||||
if (running) {
|
if (running) {
|
||||||
@@ -113,7 +141,7 @@ function createIndexerService({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// A build in flight when the service is stopped (e.g. a manual rebuild
|
// A build in flight when the service is stopped (e.g. a manual rebuild
|
||||||
// tears down the worker) is a deliberate cancellation, not a failure.
|
// tears down the worker) is a deliberate cancellation, not a failure.
|
||||||
if (!stopped) logger.warn?.(`Obelisk index build failed: ${error.message}`);
|
if (!stopped) logger.warn?.(`Obelisk index build failed: ${(error as Error).message}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
running = false;
|
running = false;
|
||||||
@@ -125,7 +153,7 @@ function createIndexerService({
|
|||||||
return idlePromise;
|
return idlePromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleBuild = (reason = 'change', changedPath = undefined) => {
|
const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
addChangedPath(changedPath);
|
addChangedPath(changedPath);
|
||||||
lastReason = reason;
|
lastReason = reason;
|
||||||
@@ -165,7 +193,7 @@ function createIndexerService({
|
|||||||
try {
|
try {
|
||||||
writeHeartbeat();
|
writeHeartbeat();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn?.(`Obelisk heartbeat failed: ${error.message}`);
|
logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}, heartbeatMs);
|
}, heartbeatMs);
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,41 @@ import { Worker } from 'node:worker_threads';
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
interface WorkerMessage {
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message: string; stack?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingBuild {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerBuildIndexOptions {
|
||||||
|
workerPath?: string;
|
||||||
|
WorkerImpl?: typeof Worker;
|
||||||
|
}
|
||||||
|
|
||||||
function createWorkerBuildIndex({
|
function createWorkerBuildIndex({
|
||||||
|
// indexer-worker.js is the built worker output emitted next to this module.
|
||||||
workerPath = path.join(__dirname, 'indexer-worker.js'),
|
workerPath = path.join(__dirname, 'indexer-worker.js'),
|
||||||
WorkerImpl = Worker,
|
WorkerImpl = Worker,
|
||||||
} = {}) {
|
}: WorkerBuildIndexOptions = {}) {
|
||||||
let worker = null;
|
let worker: Worker | null = null;
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const pending = new Map();
|
const pending = new Map<number, PendingBuild>();
|
||||||
|
|
||||||
const rejectPending = (error) => {
|
const rejectPending = (error: Error) => {
|
||||||
for (const { reject } of pending.values()) reject(error);
|
for (const { reject } of pending.values()) reject(error);
|
||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureWorker = () => {
|
const ensureWorker = (): Worker => {
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
worker = new WorkerImpl(workerPath, { type: 'module' });
|
const active = new WorkerImpl(workerPath, { type: 'module' } as ConstructorParameters<typeof Worker>[1]);
|
||||||
worker.on('message', (message) => {
|
worker = active;
|
||||||
|
active.on('message', (message: WorkerMessage) => {
|
||||||
const current = pending.get(message.id);
|
const current = pending.get(message.id);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
pending.delete(message.id);
|
pending.delete(message.id);
|
||||||
@@ -32,18 +50,18 @@ function createWorkerBuildIndex({
|
|||||||
current.resolve(message.result);
|
current.resolve(message.result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
worker.on('error', (error) => {
|
active.on('error', (error: Error) => {
|
||||||
rejectPending(error);
|
rejectPending(error);
|
||||||
worker = null;
|
worker = null;
|
||||||
});
|
});
|
||||||
worker.on('exit', (code) => {
|
active.on('exit', (code: number) => {
|
||||||
if (pending.size) rejectPending(new Error(`Indexer worker exited with code ${code}`));
|
if (pending.size) rejectPending(new Error(`Indexer worker exited with code ${code}`));
|
||||||
worker = null;
|
worker = null;
|
||||||
});
|
});
|
||||||
return worker;
|
return active;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildIndex = (args = {}) => new Promise((resolve, reject) => {
|
const buildIndex = (args: Record<string, unknown> = {}) => new Promise((resolve, reject) => {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
pending.set(id, { resolve, reject });
|
pending.set(id, { resolve, reject });
|
||||||
ensureWorker().postMessage({ id, args });
|
ensureWorker().postMessage({ id, args });
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { parentPort } from 'node:worker_threads';
|
|
||||||
import { buildIndex } from './indexer.js';
|
|
||||||
|
|
||||||
parentPort.on('message', ({ id, args }) => {
|
|
||||||
try {
|
|
||||||
const result = buildIndex(args || {});
|
|
||||||
parentPort.postMessage({ id, result });
|
|
||||||
} catch (error) {
|
|
||||||
parentPort.postMessage({
|
|
||||||
id,
|
|
||||||
error: {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { parentPort } from 'node:worker_threads';
|
||||||
|
import { buildIndex } from './indexer.ts';
|
||||||
|
|
||||||
|
if (!parentPort) throw new Error('indexer-worker must run as a worker thread');
|
||||||
|
const port = parentPort;
|
||||||
|
|
||||||
|
port.on('message', ({ id, args }: { id: number; args?: Record<string, unknown> }) => {
|
||||||
|
try {
|
||||||
|
const result = buildIndex(args || {});
|
||||||
|
port.postMessage({ id, result });
|
||||||
|
} catch (error) {
|
||||||
|
port.postMessage({
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,12 +25,22 @@ const DEFAULT_DB_PATH = path.join(DEFAULT_OBELISK_DIR, 'obelisk.sqlite');
|
|||||||
const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects');
|
const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects');
|
||||||
const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl');
|
const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl');
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
path: string;
|
||||||
|
sessionId?: string;
|
||||||
|
project?: string;
|
||||||
|
isSubagent?: boolean;
|
||||||
|
agentId?: string;
|
||||||
|
workflowRunId?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSchemaPath() {
|
function resolveSchemaPath() {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(__dirname, 'schema.sql'),
|
path.join(__dirname, 'schema.sql'),
|
||||||
path.join(__dirname, '..', '..', '..', 'scripts', 'schema.sql'),
|
path.join(__dirname, '..', '..', '..', 'scripts', 'schema.sql'),
|
||||||
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
|
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
|
||||||
].filter(Boolean);
|
].filter((c): c is string => Boolean(c));
|
||||||
const found = candidates.find(p => fs.existsSync(p));
|
const found = candidates.find(p => fs.existsSync(p));
|
||||||
if (!found) throw new Error('Obelisk schema.sql not found');
|
if (!found) throw new Error('Obelisk schema.sql not found');
|
||||||
return found;
|
return found;
|
||||||
@@ -41,7 +51,7 @@ function installSchema(db, schemaPath = resolveSchemaPath()) {
|
|||||||
migrateDb(db);
|
migrateDb(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database } = {}) {
|
function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database }: { dbPath?: string; schemaPath?: string; DatabaseImpl?: new (dbPath: string) => any } = {}) {
|
||||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
const db = new DatabaseImpl(dbPath);
|
const db = new DatabaseImpl(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
@@ -104,7 +114,7 @@ function copyMemoriesFromDb(db, sourceDbPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined } = {}) {
|
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined }: { projectsDir?: string; changedPaths?: string[] } = {}) {
|
||||||
if (Array.isArray(changedPaths) && changedPaths.length) {
|
if (Array.isArray(changedPaths) && changedPaths.length) {
|
||||||
const changedFiles = discoverJsonlFilesForChanges({ projectsDir, changedPaths });
|
const changedFiles = discoverJsonlFilesForChanges({ projectsDir, changedPaths });
|
||||||
if (changedFiles.length) return changedFiles;
|
if (changedFiles.length) return changedFiles;
|
||||||
@@ -168,8 +178,8 @@ function dedupeFileInfos(files) {
|
|||||||
return [...byPath.values()];
|
return [...byPath.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] } = {}) {
|
function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] }: { projectsDir?: string; changedPaths?: string[] } = {}) {
|
||||||
const files = [];
|
const files: FileInfo[] = [];
|
||||||
for (const changedPath of changedPaths) {
|
for (const changedPath of changedPaths) {
|
||||||
const info = jsonlFileInfoFromPath(projectsDir, changedPath);
|
const info = jsonlFileInfoFromPath(projectsDir, changedPath);
|
||||||
if (info) files.push(info);
|
if (info) files.push(info);
|
||||||
@@ -178,7 +188,7 @@ function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
||||||
const files = [];
|
const files: FileInfo[] = [];
|
||||||
if (!fs.existsSync(projectsDir)) return files;
|
if (!fs.existsSync(projectsDir)) return files;
|
||||||
let projects;
|
let projects;
|
||||||
try { projects = fs.readdirSync(projectsDir); } catch { return files; }
|
try { projects = fs.readdirSync(projectsDir); } catch { return files; }
|
||||||
@@ -219,7 +229,7 @@ function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverCodexJsonlFiles({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined } = {}) {
|
function discoverCodexJsonlFiles({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined }: { codexDir?: string; changedPaths?: string[] } = {}) {
|
||||||
if (Array.isArray(changedPaths) && changedPaths.length) {
|
if (Array.isArray(changedPaths) && changedPaths.length) {
|
||||||
const changedFiles = discoverCodexJsonlFilesForChanges({ codexDir, changedPaths });
|
const changedFiles = discoverCodexJsonlFilesForChanges({ codexDir, changedPaths });
|
||||||
if (changedFiles.length) return changedFiles;
|
if (changedFiles.length) return changedFiles;
|
||||||
@@ -244,8 +254,8 @@ function isPathInside(rootDir, candidate) {
|
|||||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, changedPaths = [] } = {}) {
|
function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, changedPaths = [] }: { codexDir?: string; changedPaths?: string[] } = {}) {
|
||||||
const files = [];
|
const files: FileInfo[] = [];
|
||||||
const sessionsDir = codexSessionsDir(codexDir);
|
const sessionsDir = codexSessionsDir(codexDir);
|
||||||
for (const changedPath of changedPaths) {
|
for (const changedPath of changedPaths) {
|
||||||
const rootRelativePath = normalizeChangedPathForRoot(codexDir, changedPath);
|
const rootRelativePath = normalizeChangedPathForRoot(codexDir, changedPath);
|
||||||
@@ -255,7 +265,7 @@ function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, chang
|
|||||||
}
|
}
|
||||||
const sessionRelativePath = normalizeChangedPathForRoot(sessionsDir, changedPath);
|
const sessionRelativePath = normalizeChangedPathForRoot(sessionsDir, changedPath);
|
||||||
const fp = isPathInside(sessionsDir, rootRelativePath) ? rootRelativePath : sessionRelativePath;
|
const fp = isPathInside(sessionsDir, rootRelativePath) ? rootRelativePath : sessionRelativePath;
|
||||||
if (!fp.endsWith('.jsonl') || !isPathInside(sessionsDir, fp)) continue;
|
if (!fp || !fp.endsWith(".jsonl") || !isPathInside(sessionsDir, fp)) continue;
|
||||||
if (!fs.existsSync(fp)) continue;
|
if (!fs.existsSync(fp)) continue;
|
||||||
files.push({ path: fp, source: 'codex' });
|
files.push({ path: fp, source: 'codex' });
|
||||||
}
|
}
|
||||||
@@ -264,11 +274,11 @@ function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, chang
|
|||||||
|
|
||||||
function discoverCodexJsonlFilesFull({ codexDir = DEFAULT_CODEX_DIR } = {}) {
|
function discoverCodexJsonlFilesFull({ codexDir = DEFAULT_CODEX_DIR } = {}) {
|
||||||
const root = codexSessionsDir(codexDir);
|
const root = codexSessionsDir(codexDir);
|
||||||
const files = [];
|
const files: FileInfo[] = [];
|
||||||
if (!fs.existsSync(root)) return files;
|
if (!fs.existsSync(root)) return files;
|
||||||
const stack = [root];
|
const stack = [root];
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const current = stack.pop();
|
const current = stack.pop()!;
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -308,7 +318,7 @@ function indexClaudeFile(db, file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function codexSessionMeta(filePath) {
|
function codexSessionMeta(filePath) {
|
||||||
let meta = null;
|
let meta: any = null;
|
||||||
readLines(filePath, (line) => {
|
readLines(filePath, (line) => {
|
||||||
let obj;
|
let obj;
|
||||||
try { obj = JSON.parse(line); } catch { return; }
|
try { obj = JSON.parse(line); } catch { return; }
|
||||||
@@ -328,7 +338,7 @@ function indexCodexFile(db, file) {
|
|||||||
if (!needed) {
|
if (!needed) {
|
||||||
if (guardian) {
|
if (guardian) {
|
||||||
persist(db, { key: file.path, sessionId: '' }, (function* () {
|
persist(db, { key: file.path, sessionId: '' }, (function* () {
|
||||||
yield { kind: 'delete-session', sessionId: codexDbId(guardian.threadRawId) };
|
yield { kind: "delete-session", sessionId: codexDbId(guardian.threadRawId) as string };
|
||||||
return null;
|
return null;
|
||||||
})());
|
})());
|
||||||
}
|
}
|
||||||
@@ -352,7 +362,7 @@ function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) {
|
|||||||
db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?')
|
db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?')
|
||||||
.run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex');
|
.run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: malformed Codex session index line: ${error.message}`);
|
console.warn(`Warning: malformed Codex session index line: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -387,7 +397,7 @@ function indexSubagentMeta(db, fi) {
|
|||||||
db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0);
|
db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: failed to read subagent meta ${mp}: ${error.message}`);
|
console.warn(`Warning: failed to read subagent meta ${mp}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +433,7 @@ function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
|||||||
item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId);
|
item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: failed to index workflow ${f}: ${error.message}`);
|
console.warn(`Warning: failed to index workflow ${f}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +447,7 @@ function indexHistory(db, { historyPath = DEFAULT_HISTORY_PATH } = {}) {
|
|||||||
const o = JSON.parse(line);
|
const o = JSON.parse(line);
|
||||||
if (o.sessionId && o.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(o.title, o.sessionId);
|
if (o.sessionId && o.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(o.title, o.sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: malformed history line: ${error.message}`);
|
console.warn(`Warning: malformed history line: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -488,6 +498,26 @@ function writeHeartbeat({ dbPath = DEFAULT_DB_PATH, DatabaseImpl = Database } =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BuildIndexOptions {
|
||||||
|
claudeDir?: string;
|
||||||
|
codexDir?: string;
|
||||||
|
projectsDir?: string;
|
||||||
|
historyPath?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
schemaPath?: string;
|
||||||
|
DatabaseImpl?: new (dbPath: string) => any;
|
||||||
|
force?: boolean;
|
||||||
|
changedPaths?: string[];
|
||||||
|
preserveDbPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildIndexResult {
|
||||||
|
files: number;
|
||||||
|
latestSourceMtime: number;
|
||||||
|
affectedSessionIds: string[];
|
||||||
|
ftsRebuilt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function buildIndex({
|
function buildIndex({
|
||||||
claudeDir = DEFAULT_CLAUDE_DIR,
|
claudeDir = DEFAULT_CLAUDE_DIR,
|
||||||
codexDir = path.join(path.dirname(claudeDir), '.codex'),
|
codexDir = path.join(path.dirname(claudeDir), '.codex'),
|
||||||
@@ -499,7 +529,7 @@ function buildIndex({
|
|||||||
force = false,
|
force = false,
|
||||||
changedPaths = undefined,
|
changedPaths = undefined,
|
||||||
preserveDbPath = null,
|
preserveDbPath = null,
|
||||||
} = {}) {
|
}: BuildIndexOptions = {}): BuildIndexResult {
|
||||||
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
|
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
|
||||||
let messageFtsTriggersDropped = false;
|
let messageFtsTriggersDropped = false;
|
||||||
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
|
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
|
||||||
@@ -531,7 +561,7 @@ function buildIndex({
|
|||||||
db.prepare("DELETE FROM workflows").run();
|
db.prepare("DELETE FROM workflows").run();
|
||||||
db.prepare("DELETE FROM workflow_agents").run();
|
db.prepare("DELETE FROM workflow_agents").run();
|
||||||
}
|
}
|
||||||
const affectedSessionIds = new Set();
|
const affectedSessionIds = new Set<string>();
|
||||||
if (Array.isArray(changedPaths)) {
|
if (Array.isArray(changedPaths)) {
|
||||||
for (const changedPath of changedPaths) {
|
for (const changedPath of changedPaths) {
|
||||||
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
|
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
|
||||||
@@ -547,7 +577,7 @@ function buildIndex({
|
|||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
console.warn(`Warning: failed to index ${file.path}: ${error.message}`);
|
console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
@@ -575,7 +605,7 @@ function buildIndex({
|
|||||||
try {
|
try {
|
||||||
installSchema(db, schemaPath);
|
installSchema(db, schemaPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: failed to restore message FTS triggers: ${error.message}`);
|
console.warn(`Warning: failed to restore message FTS triggers: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkpointDb(db);
|
checkpointDb(db);
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
function cleanRecapFilename(filename) {
|
function cleanRecapFilename(filename?: string | null): string {
|
||||||
if (!filename) return '';
|
if (!filename) return '';
|
||||||
return path.basename(String(filename));
|
return path.basename(String(filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = {}) {
|
interface RecapExportQueryOptions {
|
||||||
|
cardIdx?: number | string;
|
||||||
|
archetype?: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' }: RecapExportQueryOptions = {}): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const cardNumber = Number(cardIdx);
|
const cardNumber = Number(cardIdx);
|
||||||
params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0');
|
params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0');
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('obelisk', {
|
|
||||||
getSessions: (opts) => ipcRenderer.invoke('db:getSessions', opts),
|
|
||||||
getSessionMessages: (id) => ipcRenderer.invoke('db:getSessionMessages', id),
|
|
||||||
getSessionToolCalls: (id) => ipcRenderer.invoke('db:getSessionToolCalls', id),
|
|
||||||
getSessionToolResults: (id) => ipcRenderer.invoke('db:getSessionToolResults', id),
|
|
||||||
getSessionSubagents: (id) => ipcRenderer.invoke('db:getSessionSubagents', id),
|
|
||||||
getSessionWorkflows: (id) => ipcRenderer.invoke('db:getSessionWorkflows', id),
|
|
||||||
getSubagentMessages: (agentId) => ipcRenderer.invoke('db:getSubagentMessages', agentId),
|
|
||||||
getSubagentToolCalls: (agentId) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId),
|
|
||||||
getSubagentToolResults: (agentId) => ipcRenderer.invoke('db:getSubagentToolResults', agentId),
|
|
||||||
getSessionSummaries: (id) => ipcRenderer.invoke('db:getSessionSummaries', id),
|
|
||||||
getMessageFullText: (uuid) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
|
||||||
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
|
||||||
readMemoryFile: (path) => ipcRenderer.invoke('db:readMemoryFile', path),
|
|
||||||
archiveMemory: (id, reason) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
|
||||||
restoreMemory: (id) => ipcRenderer.invoke('db:restoreMemory', id),
|
|
||||||
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
|
||||||
getStats: () => ipcRenderer.invoke('db:getStats'),
|
|
||||||
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
|
||||||
onIndexUpdated: (callback) => {
|
|
||||||
const listener = (_, payload) => callback(payload);
|
|
||||||
ipcRenderer.on('obelisk:index-updated', listener);
|
|
||||||
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
|
|
||||||
},
|
|
||||||
onSessionUpdated: (callback) => {
|
|
||||||
const listener = (_, payload) => callback(payload);
|
|
||||||
ipcRenderer.on('obelisk:session-updated', listener);
|
|
||||||
return () => ipcRenderer.removeListener('obelisk:session-updated', listener);
|
|
||||||
},
|
|
||||||
captureExport: (opts) => ipcRenderer.invoke('capture:export', opts),
|
|
||||||
copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts),
|
|
||||||
recapList: () => ipcRenderer.invoke('recap:list'),
|
|
||||||
recapRead: (filename) => ipcRenderer.invoke('recap:read', filename),
|
|
||||||
onRecapUpdated: (callback) => {
|
|
||||||
const listener = (_, filePath) => callback(filePath);
|
|
||||||
ipcRenderer.on('obelisk:recap-updated', listener);
|
|
||||||
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
|
|
||||||
},
|
|
||||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
|
||||||
browseFolder: () => ipcRenderer.invoke('settings:browseFolder'),
|
|
||||||
setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value),
|
|
||||||
revealPath: (p) => ipcRenderer.invoke('settings:revealPath', p),
|
|
||||||
rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'),
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('obelisk', {
|
||||||
|
getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts),
|
||||||
|
getSessionMessages: (id: string) => ipcRenderer.invoke('db:getSessionMessages', id),
|
||||||
|
getSessionToolCalls: (id: string) => ipcRenderer.invoke('db:getSessionToolCalls', id),
|
||||||
|
getSessionToolResults: (id: string) => ipcRenderer.invoke('db:getSessionToolResults', id),
|
||||||
|
getSessionSubagents: (id: string) => ipcRenderer.invoke('db:getSessionSubagents', id),
|
||||||
|
getSessionWorkflows: (id: string) => ipcRenderer.invoke('db:getSessionWorkflows', id),
|
||||||
|
getSubagentMessages: (agentId: string) => ipcRenderer.invoke('db:getSubagentMessages', agentId),
|
||||||
|
getSubagentToolCalls: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId),
|
||||||
|
getSubagentToolResults: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolResults', agentId),
|
||||||
|
getSessionSummaries: (id: string) => ipcRenderer.invoke('db:getSessionSummaries', id),
|
||||||
|
getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
||||||
|
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
||||||
|
readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path),
|
||||||
|
archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
||||||
|
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||||
|
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||||
|
getStats: () => ipcRenderer.invoke('db:getStats'),
|
||||||
|
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
||||||
|
onIndexUpdated: (callback: (payload: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
|
||||||
|
ipcRenderer.on('obelisk:index-updated', listener);
|
||||||
|
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
|
||||||
|
},
|
||||||
|
onSessionUpdated: (callback: (payload: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
|
||||||
|
ipcRenderer.on('obelisk:session-updated', listener);
|
||||||
|
return () => ipcRenderer.removeListener('obelisk:session-updated', listener);
|
||||||
|
},
|
||||||
|
captureExport: (opts?: unknown) => ipcRenderer.invoke('capture:export', opts),
|
||||||
|
copyImage: (opts?: unknown) => ipcRenderer.invoke('capture:copy', opts),
|
||||||
|
recapList: () => ipcRenderer.invoke('recap:list'),
|
||||||
|
recapRead: (filename: string) => ipcRenderer.invoke('recap:read', filename),
|
||||||
|
onRecapUpdated: (callback: (filePath: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, filePath: unknown) => callback(filePath);
|
||||||
|
ipcRenderer.on('obelisk:recap-updated', listener);
|
||||||
|
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
|
||||||
|
},
|
||||||
|
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||||
|
browseFolder: () => ipcRenderer.invoke('settings:browseFolder'),
|
||||||
|
setSetting: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value),
|
||||||
|
revealPath: (p: string) => ipcRenderer.invoke('settings:revealPath', p),
|
||||||
|
rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*", "src/preload/**/*"],
|
||||||
|
"exclude": ["node_modules", "out", "dist", "release", "src/renderer"]
|
||||||
|
}
|
||||||
@@ -27,11 +27,32 @@ decisions within this:
|
|||||||
(ADR-0003) remains for the skill artifact; the app does not need it.
|
(ADR-0003) remains for the skill artifact; the app does not need it.
|
||||||
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and
|
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and
|
||||||
unpacked from the asar.
|
unpacked from the asar.
|
||||||
|
- **The app main + preload source is TypeScript with types at its seams**, but
|
||||||
|
under a *deliberately more lenient* project than the runtime core. `app/tsconfig.json`
|
||||||
|
keeps `strict` on yet sets `noImplicitAny: false`, because the app mostly
|
||||||
|
orchestrates the already-strictly-typed core (`scripts/`), and annotating every
|
||||||
|
internal SQLite-handle helper would be high-cost, low-value churn. Types are
|
||||||
|
added where they matter: the core-consumption seam (`BuildIndexOptions`/
|
||||||
|
`BuildIndexResult`, `FileInfo`), the service/worker factories, and the IPC
|
||||||
|
bridge. Module-to-module specifiers use the real `.ts` extension (mirroring
|
||||||
|
`scripts/`, since Node's type-stripping does not rewrite `.js`→`.ts`), which
|
||||||
|
needs `allowImportingTsExtensions` (safe under the project's `noEmit`); the
|
||||||
|
worker's *runtime* path stays `indexer-worker.js` because that is the built
|
||||||
|
output. `@types/better-sqlite3` is a devDependency for the injected binding.
|
||||||
|
|
||||||
|
**Two-tier typechecking.** `npm run typecheck` runs the root project (`scripts/` +
|
||||||
|
`tests/`, fully strict including `noImplicitAny`) and then the app project. The
|
||||||
|
root project **excludes the app-importing tests** (`tests/app-*.test.mjs`,
|
||||||
|
`tests/recap-capture-query.test.mjs`): those tests import app source, which would
|
||||||
|
otherwise drag the lenient app files into the strict root program and fail on
|
||||||
|
implicit `any`. The app source is instead covered by `app/tsconfig.json`, so
|
||||||
|
nothing loses type coverage — the strict core and the lenient app are checked by
|
||||||
|
the project that owns each, and never mixed.
|
||||||
|
|
||||||
**Consequences.** The app is restructured into `src/{main,preload,renderer}` with
|
**Consequences.** The app is restructured into `src/{main,preload,renderer}` with
|
||||||
`electron.vite.config.ts`; each main module is a build input so CommonJS-style
|
`electron.vite.config.ts`; each main module is a build input so relative imports
|
||||||
require resolution and the indexer worker (`{ type: 'module' }`) resolve at
|
between them and the indexer worker (`{ type: 'module' }`) resolve at runtime.
|
||||||
runtime. `npm run dev` is `electron-vite dev`. Tests that loaded app modules moved
|
`npm run dev` is `electron-vite dev`. Tests that loaded app modules moved
|
||||||
to ESM imports, and `app-main-settings` was rewritten from CJS `Module._load`
|
to ESM imports, and `app-main-settings` was rewritten from CJS `Module._load`
|
||||||
mocking to `node:test` `mock.module` (needs `--experimental-test-module-mocks`).
|
mocking to `node:test` `mock.module` (needs `--experimental-test-module-mocks`).
|
||||||
A future contributor may be tempted to make the preload ESM or disable the
|
A future contributor may be tempted to make the preload ESM or disable the
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
|
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"build:core": "rm -rf dist && tsc -p tsconfig.build.json"
|
"build:core": "rm -rf dist && tsc -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
import { createIndexerService } from '../app/src/main/indexer-service.js';
|
import { createIndexerService } from '../app/src/main/indexer-service.ts';
|
||||||
|
|
||||||
function manualTimers() {
|
function manualTimers() {
|
||||||
const timers = new Set();
|
const timers = new Set();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
import { createWorkerBuildIndex } from '../app/src/main/indexer-worker-client.js';
|
import { createWorkerBuildIndex } from '../app/src/main/indexer-worker-client.ts';
|
||||||
|
|
||||||
test('worker build client resolves build results from a worker thread', async () => {
|
test('worker build client resolves build results from a worker thread', async () => {
|
||||||
const instances = [];
|
const instances = [];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
import { buildIndex } from '../app/src/main/indexer.js';
|
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
class TestDatabase {
|
class TestDatabase {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const require = createRequire(import.meta.url);
|
|||||||
// resolve each bare specifier exactly as the main module sees it (ESM resolution
|
// resolve each bare specifier exactly as the main module sees it (ESM resolution
|
||||||
// relative to the main module's directory) and mock that URL. Relative deps are
|
// relative to the main module's directory) and mock that URL. Relative deps are
|
||||||
// resolved against the main module URL directly.
|
// resolved against the main module URL directly.
|
||||||
const mainUrl = new URL('../app/src/main/index.js', import.meta.url);
|
const mainUrl = new URL('../app/src/main/index.ts', import.meta.url);
|
||||||
const mainPath = fileURLToPath(mainUrl);
|
const mainPath = fileURLToPath(mainUrl);
|
||||||
const mainDir = fileURLToPath(new URL('.', mainUrl));
|
const mainDir = fileURLToPath(new URL('.', mainUrl));
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ function esmResolve(specifier) {
|
|||||||
const ELECTRON_URL = esmResolve('electron');
|
const ELECTRON_URL = esmResolve('electron');
|
||||||
const DATABASE_URL = esmResolve('better-sqlite3');
|
const DATABASE_URL = esmResolve('better-sqlite3');
|
||||||
const CHOKIDAR_URL = esmResolve('chokidar');
|
const CHOKIDAR_URL = esmResolve('chokidar');
|
||||||
const INDEXER_URL = new URL('./indexer.js', mainUrl).href;
|
const INDEXER_URL = new URL('./indexer.ts', mainUrl).href;
|
||||||
const INDEXER_SERVICE_URL = new URL('./indexer-service.js', mainUrl).href;
|
const INDEXER_SERVICE_URL = new URL('./indexer-service.ts', mainUrl).href;
|
||||||
const INDEXER_WORKER_URL = new URL('./indexer-worker-client.js', mainUrl).href;
|
const INDEXER_WORKER_URL = new URL('./indexer-worker-client.ts', mainUrl).href;
|
||||||
|
|
||||||
let importCounter = 0;
|
let importCounter = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
import { buildRecapExportQuery, cleanRecapFilename } from '../app/src/main/recap-capture-query.js';
|
import { buildRecapExportQuery, cleanRecapFilename } from '../app/src/main/recap-capture-query.ts';
|
||||||
|
|
||||||
test('recap export query includes the selected recap filename', () => {
|
test('recap export query includes the selected recap filename', () => {
|
||||||
const query = buildRecapExportQuery({
|
const query = buildRecapExportQuery({
|
||||||
|
|||||||
+8
-1
@@ -16,5 +16,12 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["scripts/**/*", "tests/**/*"],
|
"include": ["scripts/**/*", "tests/**/*"],
|
||||||
"exclude": ["node_modules", "app", "dist", "release"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"app",
|
||||||
|
"dist",
|
||||||
|
"release",
|
||||||
|
"tests/app-*.test.mjs",
|
||||||
|
"tests/recap-capture-query.test.mjs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user