diff --git a/server/config.js b/server/config.js index a1a9e54..0e232c0 100644 --- a/server/config.js +++ b/server/config.js @@ -1,7 +1,5 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); const { createStorage } = require('./storage'); const { resolveLogsDir } = require('./paths'); @@ -188,7 +186,6 @@ function joinUpstreamPath(upstream, requestUrl) { return basePath + (urlPath.startsWith('/') ? urlPath : `/${urlPath}`); } const LOGS_DIR = resolveLogsDir(); -const LEGACY_LOGS_DIR = path.join(__dirname, '..', 'logs'); const RESTORE_DAYS = parseInt(process.env.RESTORE_DAYS || '3', 10); const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '14', 10); // 0 = only session-start anchor; N>0 = force full snapshot every N delta writes @@ -279,23 +276,11 @@ function inferMaxContext(model, system, usage) { return base; } -// Ensure logs dir exists; migrate from legacy location if needed -if (!fs.existsSync(LOGS_DIR)) { - fs.mkdirSync(LOGS_DIR, { recursive: true }); - // One-time migration from old package-relative logs/ - const legacyIndex = path.join(LEGACY_LOGS_DIR, 'index.ndjson'); - if (fs.existsSync(legacyIndex)) { - try { - const files = fs.readdirSync(LEGACY_LOGS_DIR); - for (const f of files) { - fs.renameSync(path.join(LEGACY_LOGS_DIR, f), path.join(LOGS_DIR, f)); - } - console.log(`Migrated logs from ${LEGACY_LOGS_DIR} → ${LOGS_DIR}`); - } catch (e) { - console.error(`Log migration failed: ${e.message}`); - } - } -} +// Logs-dir creation and the one-time legacy-logs migration now live in the +// local storage adapter's init() (server/storage/local.js), invoked once at +// startup via `await config.storage.init()`. config.js performs no filesystem +// side effects at require time, and the migration only runs for the local +// backend (S3/R2 never reads LOGS_DIR). module.exports = { PORT, diff --git a/server/storage/index.js b/server/storage/index.js index fa16539..ef8958b 100644 --- a/server/storage/index.js +++ b/server/storage/index.js @@ -1,7 +1,14 @@ 'use strict'; +const path = require('path'); const { createLocalStorage } = require('./local'); +// Old package-relative logs/ location (/logs). The local adapter migrates +// from here into the resolved logs dir on first init(). Resolved here — at the +// single point where the local adapter is constructed — so it never runs at +// config-import time and never applies to non-local backends (e.g. S3/R2). +const LEGACY_LOGS_DIR = path.join(__dirname, '..', '..', 'logs'); + // Wraps a storage adapter so every async write is tracked in an in-flight Set. // drain() awaits all pending writes — used on shutdown so process.exit doesn't // kill the event loop while fs.writeFile is mid-flight, leaving 0-byte files. @@ -54,7 +61,7 @@ function createStorage() { case 'local': default: { const { resolveLogsDir } = require('../paths'); - adapter = createLocalStorage(resolveLogsDir()); + adapter = createLocalStorage(resolveLogsDir(), { legacyDir: LEGACY_LOGS_DIR }); break; } } diff --git a/server/storage/local.js b/server/storage/local.js index 4e9fbc7..e52c9f4 100644 --- a/server/storage/local.js +++ b/server/storage/local.js @@ -7,11 +7,34 @@ const path = require('path'); /** * Local filesystem storage adapter. * @param {string} logsDir — absolute path to the logs directory + * @param {object} [opts] + * @param {string} [opts.legacyDir] — absolute path to a pre-existing + * package-relative logs/ directory to migrate from on first init(). When + * omitted, no legacy migration is attempted. This logic lives only on the + * local adapter, so non-local backends (e.g. S3) never touch the local FS. * @returns {import('./interface').StorageAdapter} */ -function createLocalStorage(logsDir) { +function createLocalStorage(logsDir, opts = {}) { const sharedDir = path.join(logsDir, 'shared'); const indexPath = path.join(logsDir, 'index.ndjson'); + const legacyDir = opts.legacyDir || null; + + // One-time, best-effort migration from the old package-relative logs/ + // location. Errors are logged and swallowed — a failed migration must never + // crash startup (mirrors the original catch-and-log behavior). + async function migrateLegacyLogs() { + if (!legacyDir) return; + const legacyIndex = path.join(legacyDir, 'index.ndjson'); + if (!fs.existsSync(legacyIndex)) return; + try { + for (const f of await fsp.readdir(legacyDir)) { + await fsp.rename(path.join(legacyDir, f), path.join(logsDir, f)); + } + console.log(`Migrated logs from ${legacyDir} → ${logsDir}`); + } catch (e) { + console.error(`Log migration failed: ${e.message}`); + } + } return { supportsDelta: true, @@ -20,8 +43,13 @@ function createLocalStorage(logsDir) { location: logsDir, async init() { + // Snapshot before mkdir so we migrate only into a freshly-created logs + // dir — mirrors the original `if (!fs.existsSync(LOGS_DIR))` guard and + // never clobbers a populated logs dir. + const logsDirExisted = fs.existsSync(logsDir); await fsp.mkdir(logsDir, { recursive: true }); await fsp.mkdir(sharedDir, { recursive: true }); + if (!logsDirExisted) await migrateLegacyLogs(); }, async write(id, suffix, data) { diff --git a/test/legacy-migration.test.js b/test/legacy-migration.test.js new file mode 100644 index 0000000..30e5368 --- /dev/null +++ b/test/legacy-migration.test.js @@ -0,0 +1,150 @@ +'use strict'; + +// Covers the legacy-logs migration after it was folded into the local storage +// adapter's init() (it previously ran as a require-time side effect in +// config.js — fired on every import, ignored STORAGE_BACKEND). Properties: +// 1. local init() migrates a fresh logs dir from the legacy dir; +// 2. no legacy index.ndjson → no migration; +// 3. a pre-existing logs dir blocks migration (never clobbers); +// 4. no legacyDir wired (the S3/non-local path) → no local migration; +// 5. migration is best-effort — a rename error is swallowed, init() resolves; +// 6. requiring config.js has NO migration side effect at import time. +// All filesystem work is isolated to per-test temp dirs; the real ~/.ccxray is +// never touched. + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawn } = require('child_process'); + +const { createLocalStorage } = require('../server/storage/local'); + +function mkroot(label) { + return fs.mkdtempSync(path.join(os.tmpdir(), `ccxray-legmig-${label}-`)); +} + +describe('legacy-logs migration (local storage adapter init)', () => { + it('migrates legacy files into a freshly-created logs dir', async () => { + const root = mkroot('mig'); + const logsDir = path.join(root, 'logs'); + const legacyDir = path.join(root, 'pkglogs'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'index.ndjson'), '{"id":"old"}\n'); + fs.writeFileSync(path.join(legacyDir, '2026-01-01_req.json'), '{"model":"x"}'); + try { + await createLocalStorage(logsDir, { legacyDir }).init(); + assert.equal(fs.readFileSync(path.join(logsDir, 'index.ndjson'), 'utf8'), '{"id":"old"}\n'); + assert.ok(fs.existsSync(path.join(logsDir, '2026-01-01_req.json')), 'payload file migrated'); + assert.deepEqual(fs.readdirSync(legacyDir), [], 'legacy dir emptied'); + assert.ok(fs.existsSync(path.join(logsDir, 'shared')), 'shared/ still created by init'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does NOT migrate when the legacy dir has no index.ndjson', async () => { + const root = mkroot('noidx'); + const logsDir = path.join(root, 'logs'); + const legacyDir = path.join(root, 'pkglogs'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'stray.json'), 'orphan'); + try { + await createLocalStorage(logsDir, { legacyDir }).init(); + assert.ok(!fs.existsSync(path.join(logsDir, 'stray.json')), 'no migration without index.ndjson sentinel'); + assert.ok(fs.existsSync(path.join(legacyDir, 'stray.json')), 'stray file left in place'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does NOT migrate when the logs dir already existed (no clobber)', async () => { + const root = mkroot('exist'); + const logsDir = path.join(root, 'logs'); + const legacyDir = path.join(root, 'pkglogs'); + fs.mkdirSync(logsDir, { recursive: true }); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'index.ndjson'), '{"id":"old"}\n'); + try { + await createLocalStorage(logsDir, { legacyDir }).init(); + assert.ok(!fs.existsSync(path.join(logsDir, 'index.ndjson')), 'pre-existing logs dir must block migration'); + assert.ok(fs.existsSync(path.join(legacyDir, 'index.ndjson')), 'legacy file stays put'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does NOT migrate when no legacyDir is wired (the S3 / non-local path)', async () => { + const root = mkroot('nolegacy'); + const logsDir = path.join(root, 'logs'); + const legacyDir = path.join(root, 'pkglogs'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'index.ndjson'), '{"id":"old"}\n'); + try { + // No opts → no legacyDir. This is exactly how non-local backends are + // wired: only the local branch in storage/index.js passes legacyDir. + await createLocalStorage(logsDir).init(); + assert.ok(!fs.existsSync(path.join(logsDir, 'index.ndjson')), 'no legacyDir → no migration'); + assert.ok(fs.existsSync(path.join(legacyDir, 'index.ndjson')), 'legacy untouched'); + assert.ok(fs.existsSync(logsDir), 'logs dir still created by init'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('is best-effort: a rename error is swallowed and init() still resolves', async () => { + const root = mkroot('err'); + const logsDir = path.join(root, 'logs'); + const legacyDir = path.join(root, 'pkglogs'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'index.ndjson'), '{"id":"old"}\n'); + // A legacy file named 'shared' collides with the shared/ directory that + // init() creates → fsp.rename(file → existing dir) fails. The migration + // must catch-and-log, not reject init(). + fs.writeFileSync(path.join(legacyDir, 'shared'), 'collide'); + try { + await assert.doesNotReject( + () => createLocalStorage(logsDir, { legacyDir }).init(), + 'a failed rename must not crash init()', + ); + assert.ok( + fs.existsSync(path.join(logsDir, 'shared')) && fs.statSync(path.join(logsDir, 'shared')).isDirectory(), + 'shared/ dir remains intact after the failed migration', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('config.js has no migration side effect at require time', () => { + it('requiring config.js does not create the logs dir', async () => { + // Fresh require in a child process with a temp CCXRAY_HOME whose logs/ does + // not exist. The act of importing config must not touch the filesystem. + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-pure-')); + const CONFIG = path.resolve(__dirname, '..', 'server', 'config.js'); + const snippet = ` + const fsm = require('fs'); + const c = require(${JSON.stringify(CONFIG)}); + process.stdout.write(JSON.stringify({ logsDir: c.LOGS_DIR, exists: fsm.existsSync(c.LOGS_DIR) })); + `; + const env = { ...process.env }; + delete env.LOGS_DIR; + delete env.STORAGE_BACKEND; + env.CCXRAY_HOME = home; + const out = await new Promise((resolve) => { + const ch = spawn(process.execPath, ['-e', snippet], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let s = ''; + ch.stdout.on('data', d => { s += d; }); + ch.on('exit', code => resolve({ s: s.trim(), code })); + }); + try { + assert.equal(out.code, 0, 'child should exit cleanly'); + const r = JSON.parse(out.s); + assert.equal(r.exists, false, 'require(config) must NOT create LOGS_DIR — no require-time side effect'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +});