Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 5 additions & 20 deletions server/config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
'use strict';

const path = require('path');
const fs = require('fs');
const { createStorage } = require('./storage');
const { resolveLogsDir } = require('./paths');

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion server/storage/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
'use strict';

const path = require('path');
const { createLocalStorage } = require('./local');

// Old package-relative logs/ location (<repo>/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.
Expand Down Expand Up @@ -54,7 +61,7 @@ function createStorage() {
case 'local':
default: {
const { resolveLogsDir } = require('../paths');
adapter = createLocalStorage(resolveLogsDir());
adapter = createLocalStorage(resolveLogsDir(), { legacyDir: LEGACY_LOGS_DIR });
break;
}
}
Expand Down
30 changes: 29 additions & 1 deletion server/storage/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
150 changes: 150 additions & 0 deletions test/legacy-migration.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading