refactor: remove legacy config and state aliases (PR5)

This commit is contained in:
ejclaw
2026-04-10 22:09:04 +09:00
parent 312d19c0d1
commit e5bd721ac4
18 changed files with 155 additions and 980 deletions

View File

@@ -169,7 +169,7 @@ describe('environment step legacy-room handling', () => {
exitSpy.mockRestore();
});
it('exits with failure when legacy json state files need explicit migration', async () => {
it('exits with failure when unexpected data state files are present', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
tempDirs.push(tempDir);
process.chdir(tempDir);

View File

@@ -65,11 +65,9 @@ export async function run(_args: string[]): Promise<void> {
hasAuth,
assignedRooms: roomState.assignedRooms,
legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows,
hasLegacyRegisteredGroupsJson: roomState.hasLegacyRegisteredGroupsJson,
legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired,
pendingLegacyJsonStateFiles: roomState.pendingLegacyJsonStateFiles,
legacyJsonStateMigrationRequired:
roomState.legacyJsonStateMigrationRequired,
unexpectedDataStateFiles: roomState.unexpectedDataStateFiles,
unexpectedDataStateDetected: roomState.unexpectedDataStateDetected,
hasBubblewrap,
hasSocat,
apparmorRestrictUnprivilegedUserns,
@@ -87,12 +85,9 @@ export async function run(_args: string[]): Promise<void> {
HAS_ASSIGNED_ROOMS: roomState.assignedRooms > 0,
ASSIGNED_ROOMS: roomState.assignedRooms,
LEGACY_REGISTERED_GROUP_ROWS: roomState.legacyRegisteredGroupRows,
HAS_LEGACY_REGISTERED_GROUPS_JSON: roomState.hasLegacyRegisteredGroupsJson,
LEGACY_ROOM_MIGRATION_REQUIRED: roomState.legacyRoomMigrationRequired,
PENDING_LEGACY_JSON_STATE_FILES:
roomState.pendingLegacyJsonStateFiles.join(','),
LEGACY_JSON_STATE_MIGRATION_REQUIRED:
roomState.legacyJsonStateMigrationRequired,
UNEXPECTED_DATA_STATE_FILES: roomState.unexpectedDataStateFiles.join(','),
UNEXPECTED_DATA_STATE_DETECTED: roomState.unexpectedDataStateDetected,
HAS_BWRAP: hasBubblewrap,
HAS_SOCAT: hasSocat,
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:

View File

@@ -13,7 +13,6 @@ const STEPS: Record<
runners: () => import('./runners.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
'migrate-json-state': () => import('./migrate-json-state.js'),
'migrate-room-registrations': () => import('./migrate-room-registrations.js'),
'restart-stack': () => import('./restart-stack.js'),
service: () => import('./service.js'),

View File

@@ -1,152 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Database } from 'bun:sqlite';
describe('migrate json state step', () => {
const tempRoots: string[] = [];
afterEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
vi.restoreAllMocks();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it('does not migrate legacy json state during startup and leaves files in place', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-json-init-'),
);
tempRoots.push(tempRoot);
const storeDir = path.join(tempRoot, 'store');
const dataDir = path.join(tempRoot, 'data');
fs.mkdirSync(storeDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
fs.writeFileSync(
path.join(dataDir, 'router_state.json'),
JSON.stringify({ last_timestamp: '1234' }),
);
fs.writeFileSync(
path.join(dataDir, 'sessions.json'),
JSON.stringify({ 'group-a': 'session-123' }),
);
const { _initTestDatabase, initDatabase } = await import('../src/db.js');
expect(() => initDatabase()).toThrow(
/Legacy JSON state migration required before startup/,
);
expect(fs.existsSync(path.join(dataDir, 'router_state.json'))).toBe(true);
expect(fs.existsSync(path.join(dataDir, 'sessions.json'))).toBe(true);
expect(
fs.existsSync(path.join(dataDir, 'router_state.json.migrated')),
).toBe(false);
expect(fs.existsSync(path.join(dataDir, 'sessions.json.migrated'))).toBe(
false,
);
_initTestDatabase();
});
it('migrates router_state.json and sessions.json only via explicit setup step', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-json-step-'),
);
tempRoots.push(tempRoot);
const storeDir = path.join(tempRoot, 'store');
const dataDir = path.join(tempRoot, 'data');
fs.mkdirSync(storeDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
fs.writeFileSync(
path.join(dataDir, 'router_state.json'),
JSON.stringify({
last_timestamp: '1234',
last_agent_timestamp: {
'dc:test-room': '5678',
},
}),
);
fs.writeFileSync(
path.join(dataDir, 'sessions.json'),
JSON.stringify({
'group-a': 'session-123',
'group-b': 'session-456',
}),
);
const emitStatusMock = vi.fn();
vi.doMock('./status.js', () => ({
emitStatus: emitStatusMock,
}));
const { run } = await import('./migrate-json-state.js');
await run([]);
const db = new Database(path.join(storeDir, 'messages.db'), {
readonly: true,
});
expect(
db.prepare('SELECT key, value FROM router_state ORDER BY key').all(),
).toEqual([
{
key: 'last_agent_seq',
value: '{"dc:test-room":"5678"}',
},
{
key: 'last_seq',
value: '1234',
},
]);
expect(
db
.prepare(
`SELECT group_folder, agent_type, session_id
FROM sessions
ORDER BY group_folder`,
)
.all(),
).toEqual([
{
group_folder: 'group-a',
agent_type: 'claude-code',
session_id: 'session-123',
},
{
group_folder: 'group-b',
agent_type: 'claude-code',
session_id: 'session-456',
},
]);
db.close();
expect(fs.existsSync(path.join(dataDir, 'router_state.json'))).toBe(false);
expect(fs.existsSync(path.join(dataDir, 'sessions.json'))).toBe(false);
expect(
fs.existsSync(path.join(dataDir, 'router_state.json.migrated')),
).toBe(true);
expect(fs.existsSync(path.join(dataDir, 'sessions.json.migrated'))).toBe(
true,
);
expect(emitStatusMock).toHaveBeenLastCalledWith(
'MIGRATE_JSON_STATE',
expect.objectContaining({
MIGRATED_ROUTER_STATE_KEYS: 2,
MIGRATED_SESSIONS: 2,
RENAMED_ROUTER_STATE_JSON: true,
RENAMED_SESSIONS_JSON: true,
STATUS: 'success',
}),
);
});
});

View File

@@ -1,116 +0,0 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR, STORE_DIR } from '../src/config.js';
import {
initializeDatabaseSchema,
openPersistentDatabase,
} from '../src/db/bootstrap.js';
import { setRouterStateInDatabase } from '../src/db/router-state.js';
import { setSessionInDatabase } from '../src/db/sessions.js';
import { readJsonFile } from '../src/utils.js';
import { emitStatus } from './status.js';
interface JsonMigrationReport {
migratedRouterStateKeys: number;
migratedSessions: number;
renamedRouterStateJson: boolean;
renamedSessionsJson: boolean;
}
function renameMigratedFile(filePath: string): void {
fs.renameSync(filePath, `${filePath}.migrated`);
}
function migrateRouterStateJson(
database: ReturnType<typeof openPersistentDatabase>,
): {
migratedKeys: number;
renamed: boolean;
} {
const routerStatePath = path.join(DATA_DIR, 'router_state.json');
const routerState = readJsonFile(routerStatePath) as {
last_seq?: string;
last_timestamp?: string;
last_agent_seq?: Record<string, string>;
last_agent_timestamp?: Record<string, string>;
} | null;
if (!routerState) {
return { migratedKeys: 0, renamed: false };
}
const lastSeq = routerState.last_seq ?? routerState.last_timestamp;
const lastAgentSeq =
routerState.last_agent_seq ?? routerState.last_agent_timestamp;
let migratedKeys = 0;
if (lastSeq) {
setRouterStateInDatabase(database, 'last_seq', lastSeq);
migratedKeys += 1;
}
if (lastAgentSeq) {
setRouterStateInDatabase(
database,
'last_agent_seq',
JSON.stringify(lastAgentSeq),
);
migratedKeys += 1;
}
renameMigratedFile(routerStatePath);
return { migratedKeys, renamed: true };
}
function migrateSessionsJson(
database: ReturnType<typeof openPersistentDatabase>,
): {
migratedSessions: number;
renamed: boolean;
} {
const sessionsPath = path.join(DATA_DIR, 'sessions.json');
const sessions = readJsonFile(sessionsPath) as Record<string, string> | null;
if (!sessions) {
return { migratedSessions: 0, renamed: false };
}
for (const [groupFolder, sessionId] of Object.entries(sessions)) {
setSessionInDatabase(database, groupFolder, 'claude-code', sessionId);
}
renameMigratedFile(sessionsPath);
return {
migratedSessions: Object.keys(sessions).length,
renamed: true,
};
}
export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(STORE_DIR, { recursive: true });
const database = openPersistentDatabase();
initializeDatabaseSchema(database);
const report: JsonMigrationReport = {
migratedRouterStateKeys: 0,
migratedSessions: 0,
renamedRouterStateJson: false,
renamedSessionsJson: false,
};
database.transaction(() => {
const routerStateResult = migrateRouterStateJson(database);
report.migratedRouterStateKeys = routerStateResult.migratedKeys;
report.renamedRouterStateJson = routerStateResult.renamed;
const sessionsResult = migrateSessionsJson(database);
report.migratedSessions = sessionsResult.migratedSessions;
report.renamedSessionsJson = sessionsResult.renamed;
})();
database.close();
emitStatus('MIGRATE_JSON_STATE', {
MIGRATED_ROUTER_STATE_KEYS: report.migratedRouterStateKeys,
MIGRATED_SESSIONS: report.migratedSessions,
RENAMED_ROUTER_STATE_JSON: report.renamedRouterStateJson,
RENAMED_SESSIONS_JSON: report.renamedSessionsJson,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

View File

@@ -17,7 +17,7 @@ describe('migrate room registrations step', () => {
}
});
it('migrates pending legacy rows and legacy json into canonical room tables idempotently', async () => {
it('migrates pending legacy rows into canonical room tables idempotently', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-'),
);
@@ -103,31 +103,10 @@ describe('migrate room registrations step', () => {
);
legacyDb.close();
fs.writeFileSync(
path.join(dataDir, 'registered_groups.json'),
JSON.stringify({
'dc:json-room': {
name: 'JSON Room',
folder: 'json-room',
trigger: '@Andy',
added_at: '2024-02-01T00:00:00.000Z',
agentType: 'claude-code',
requiresTrigger: true,
isMain: false,
},
}),
);
const emitStatusMock = vi.fn();
vi.doMock('./status.js', () => ({
emitStatus: emitStatusMock,
}));
vi.doMock('../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}));
const { run } = await import('./migrate-room-registrations.js');
@@ -143,12 +122,6 @@ describe('migrate room registrations step', () => {
)
.all(),
).toEqual([
{
chat_jid: 'dc:json-room',
room_mode: 'single',
mode_source: 'inferred',
owner_agent_type: 'claude-code',
},
{
chat_jid: 'dc:legacy-room',
room_mode: 'tribunal',
@@ -165,11 +138,6 @@ describe('migrate room registrations step', () => {
)
.all(),
).toEqual([
{
chat_jid: 'dc:json-room',
role: 'owner',
agent_type: 'claude-code',
},
{
chat_jid: 'dc:legacy-room',
role: 'owner',
@@ -201,22 +169,13 @@ describe('migrate room registrations step', () => {
]);
migratedDb.close();
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
false,
);
expect(
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
).toBe(true);
expect(emitStatusMock).toHaveBeenLastCalledWith(
'MIGRATE_ROOM_REGISTRATIONS',
expect.objectContaining({
MIGRATED_ROOMS: 2,
MIGRATED_ROLE_OVERRIDES: 3,
MIGRATED_ROOMS: 1,
MIGRATED_ROLE_OVERRIDES: 2,
SKIPPED_ROOMS: 0,
MIGRATED_JSON_ROOMS: 1,
SKIPPED_JSON_ROOMS: 0,
BACKED_UP_LEGACY_ROWS: 2,
RENAMED_LEGACY_JSON: true,
STATUS: 'success',
}),
);
@@ -229,10 +188,7 @@ describe('migrate room registrations step', () => {
MIGRATED_ROOMS: 0,
MIGRATED_ROLE_OVERRIDES: 0,
SKIPPED_ROOMS: 0,
MIGRATED_JSON_ROOMS: 0,
SKIPPED_JSON_ROOMS: 0,
BACKED_UP_LEGACY_ROWS: 0,
RENAMED_LEGACY_JSON: false,
STATUS: 'success',
}),
);
@@ -370,12 +326,6 @@ describe('migrate room registrations step', () => {
vi.doMock('./status.js', () => ({
emitStatus: emitStatusMock,
}));
vi.doMock('../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}));
const { run } = await import('./migrate-room-registrations.js');
@@ -417,9 +367,9 @@ describe('migrate room registrations step', () => {
);
});
it('fails and leaves registered_groups.json in place when it contains invalid entries', async () => {
it('fails when unexpected data state files are still present', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-invalid-json-'),
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-legacy-files-'),
);
tempRoots.push(tempRoot);
const storeDir = path.join(tempRoot, 'store');
@@ -429,298 +379,12 @@ describe('migrate room registrations step', () => {
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
fs.writeFileSync(
path.join(dataDir, 'registered_groups.json'),
JSON.stringify({
'dc:bad-room': {
name: 'Bad Room',
folder: '../bad',
trigger: '@Andy',
added_at: '2024-02-01T00:00:00.000Z',
},
}),
);
vi.doMock('./status.js', () => ({
emitStatus: vi.fn(),
}));
vi.doMock('../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}));
fs.writeFileSync(path.join(dataDir, 'registered_groups.json'), '{}');
const { run } = await import('./migrate-room-registrations.js');
await expect(run([])).rejects.toThrow(
/Invalid legacy registered_groups\.json folder/,
);
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
true,
);
expect(
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
).toBe(false);
const migratedDb = new Database(path.join(storeDir, 'messages.db'));
expect(
migratedDb
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name IN ('room_settings', 'registered_groups_legacy_backup')`,
)
.all(),
).toEqual([{ name: 'room_settings' }]);
expect(
migratedDb.prepare('SELECT COUNT(*) AS count FROM room_settings').get(),
).toEqual({ count: 0 });
migratedDb.close();
});
it('treats identical registered_groups.json entries as idempotent when room_settings already exists', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-json-idempotent-'),
);
tempRoots.push(tempRoot);
const storeDir = path.join(tempRoot, 'store');
const dataDir = path.join(tempRoot, 'data');
fs.mkdirSync(storeDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
const dbPath = path.join(storeDir, 'messages.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE room_settings (
chat_jid TEXT PRIMARY KEY,
room_mode TEXT NOT NULL,
mode_source TEXT NOT NULL DEFAULT 'explicit',
name TEXT,
folder TEXT,
trigger_pattern TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
owner_agent_type TEXT,
work_dir TEXT,
created_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE room_role_overrides (
chat_jid TEXT NOT NULL,
role TEXT NOT NULL,
agent_type TEXT NOT NULL,
agent_config_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (chat_jid, role)
);
`);
db.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
name,
folder,
trigger_pattern,
requires_trigger,
is_main,
owner_agent_type,
work_dir,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
'dc:json-room',
'single',
'inferred',
'JSON Room',
'json-room',
'@Andy',
1,
0,
'claude-code',
null,
'2024-02-01T00:00:00.000Z',
'2024-02-01T00:00:00.000Z',
);
db.close();
fs.writeFileSync(
path.join(dataDir, 'registered_groups.json'),
JSON.stringify({
'dc:json-room': {
name: 'JSON Room',
folder: 'json-room',
trigger: '@Andy',
added_at: '2024-02-01T00:00:00.000Z',
agentType: 'claude-code',
requiresTrigger: true,
isMain: false,
},
}),
);
const emitStatusMock = vi.fn();
vi.doMock('./status.js', () => ({
emitStatus: emitStatusMock,
}));
vi.doMock('../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}));
const { run } = await import('./migrate-room-registrations.js');
await run([]);
const migratedDb = new Database(dbPath, { readonly: true });
expect(
migratedDb
.prepare(
`SELECT chat_jid, role, agent_type
FROM room_role_overrides
ORDER BY chat_jid, role`,
)
.all(),
).toEqual([
{
chat_jid: 'dc:json-room',
role: 'owner',
agent_type: 'claude-code',
},
]);
migratedDb.close();
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
false,
);
expect(
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
).toBe(true);
expect(emitStatusMock).toHaveBeenLastCalledWith(
'MIGRATE_ROOM_REGISTRATIONS',
expect.objectContaining({
MIGRATED_ROOMS: 0,
MIGRATED_JSON_ROOMS: 0,
MIGRATED_ROLE_OVERRIDES: 1,
RENAMED_LEGACY_JSON: true,
STATUS: 'success',
}),
/Unexpected data state files detected before room migration/,
);
});
it('fails and preserves registered_groups.json when table and json owner overrides conflict', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-json-conflict-'),
);
tempRoots.push(tempRoot);
const storeDir = path.join(tempRoot, 'store');
const dataDir = path.join(tempRoot, 'data');
fs.mkdirSync(storeDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
const dbPath = path.join(storeDir, 'messages.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE registered_groups (
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
)
`);
db.prepare(
`INSERT INTO registered_groups (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
'dc:shared-room',
'Shared Room',
'shared-room',
'@Andy',
'2024-03-01T00:00:00.000Z',
'{"source":"table"}',
1,
0,
'claude-code',
null,
);
db.close();
fs.writeFileSync(
path.join(dataDir, 'registered_groups.json'),
JSON.stringify({
'dc:shared-room': {
name: 'Shared Room',
folder: 'shared-room',
trigger: '@Andy',
added_at: '2024-03-01T00:00:00.000Z',
agentType: 'claude-code',
agentConfig: { source: 'json' },
requiresTrigger: true,
isMain: false,
},
}),
);
vi.doMock('./status.js', () => ({
emitStatus: vi.fn(),
}));
vi.doMock('../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}));
const { run } = await import('./migrate-room-registrations.js');
await expect(run([])).rejects.toThrow(
/owner override conflicts with existing owner override/,
);
const migratedDb = new Database(dbPath, { readonly: true });
expect(
migratedDb.prepare('SELECT COUNT(*) AS count FROM room_settings').get(),
).toEqual({ count: 0 });
expect(
migratedDb
.prepare('SELECT COUNT(*) AS count FROM room_role_overrides')
.get(),
).toEqual({ count: 0 });
migratedDb.close();
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
true,
);
expect(
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
).toBe(false);
});
});

View File

@@ -1,9 +1,9 @@
import fs from 'fs';
import path from 'path';
import { Database } from 'bun:sqlite';
import { DATA_DIR, STORE_DIR } from '../src/config.js';
import { listUnexpectedDataStateFiles } from '../src/data-state-files.js';
import {
initializeDatabaseSchema,
openPersistentDatabase,
@@ -11,112 +11,16 @@ import {
import {
buildLegacyRoomMigrationPlan,
getPendingLegacyRegisteredGroupJids,
getStoredRoomSettingsRowFromDatabase,
insertStoredRoomSettingsFromMigration,
normalizeStoredAgentType,
upsertRoomRoleOverride,
} from '../src/db/room-registration.js';
import type { RegisteredGroup } from '../src/types.js';
import { isValidGroupFolder } from '../src/group-folder.js';
import { logger } from '../src/logger.js';
import { readJsonFile } from '../src/utils.js';
import { emitStatus } from './status.js';
interface MigrationReport {
migratedRooms: number;
migratedRoleOverrides: number;
skippedRooms: number;
migratedJsonRooms: number;
skippedJsonRooms: number;
backedUpLegacyRows: number;
renamedLegacyJson: boolean;
}
interface StoredOwnerRoleOverride {
agentType: string;
agentConfig?: RegisteredGroup['agentConfig'];
}
function normalizeAgentConfigValue(
agentConfig: RegisteredGroup['agentConfig'] | undefined,
): string {
return JSON.stringify(agentConfig ?? null);
}
function jsonEntryMatchesStoredRoom(
jid: string,
group: RegisteredGroup,
database: Database,
): boolean {
const existing = getStoredRoomSettingsRowFromDatabase(database, jid);
if (!existing) {
return false;
}
const ownerAgentType =
normalizeStoredAgentType(group.agentType) ?? 'claude-code';
return (
existing.roomMode === 'single' &&
existing.name === group.name &&
existing.folder === group.folder &&
existing.trigger === group.trigger &&
(existing.requiresTrigger ?? true) === (group.requiresTrigger ?? true) &&
(existing.isMain ?? false) === (group.isMain ?? false) &&
existing.ownerAgentType === ownerAgentType &&
(existing.workDir ?? null) === (group.workDir ?? null)
);
}
function getStoredOwnerRoleOverride(
database: Database,
jid: string,
): StoredOwnerRoleOverride | undefined {
try {
const row = database
.prepare(
`SELECT agent_type, agent_config_json
FROM room_role_overrides
WHERE chat_jid = ?
AND role = 'owner'`,
)
.get(jid) as
| {
agent_type: string | null;
agent_config_json: string | null;
}
| undefined;
const agentType = normalizeStoredAgentType(row?.agent_type);
if (!agentType) {
return undefined;
}
return {
agentType,
agentConfig: row?.agent_config_json
? JSON.parse(row.agent_config_json)
: undefined,
};
} catch {
return undefined;
}
}
function jsonEntryMatchesStoredOwnerOverride(
database: Database,
jid: string,
group: RegisteredGroup,
): boolean {
const existingOverride = getStoredOwnerRoleOverride(database, jid);
if (!existingOverride) {
return false;
}
const ownerAgentType =
normalizeStoredAgentType(group.agentType) ?? 'claude-code';
return (
existingOverride.agentType === ownerAgentType &&
normalizeAgentConfigValue(existingOverride.agentConfig) ===
normalizeAgentConfigValue(group.agentConfig)
);
}
function ensureLegacyBackupTable(database: Database): void {
@@ -217,125 +121,15 @@ function migrateLegacyRegisteredGroupsTable(
}
}
function loadValidatedLegacyRegisteredGroupsJson(
database: Database,
): Array<{ jid: string; group: RegisteredGroup }> {
const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json');
const legacyJson = readJsonFile(legacyJsonPath) as Record<
string,
RegisteredGroup
> | null;
if (!legacyJson) {
return [];
}
const entries = Object.entries(legacyJson);
for (const [jid, group] of entries) {
if (!isValidGroupFolder(group.folder)) {
throw new Error(
`Invalid legacy registered_groups.json folder for ${jid}: ${group.folder}`,
);
}
const existing = getStoredRoomSettingsRowFromDatabase(database, jid);
if (existing && !jsonEntryMatchesStoredRoom(jid, group, database)) {
throw new Error(
`Legacy registered_groups.json entry collides with existing room_settings row for ${jid}`,
);
}
}
return entries.map(([jid, group]) => ({ jid, group }));
}
function migrateLegacyRegisteredGroupsJson(
database: Database,
projectRoot: string,
report: MigrationReport,
): void {
const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json');
const legacyJsonEntries = loadValidatedLegacyRegisteredGroupsJson(database);
if (legacyJsonEntries.length === 0) {
return;
}
const insertRoom = database.prepare(
`INSERT INTO room_settings (
chat_jid,
room_mode,
mode_source,
name,
folder,
trigger_pattern,
requires_trigger,
is_main,
owner_agent_type,
work_dir,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid) DO NOTHING`,
);
for (const { jid, group } of legacyJsonEntries) {
const createdAt = group.added_at || new Date().toISOString();
const ownerAgentType =
normalizeStoredAgentType(group.agentType) ?? 'claude-code';
const existing = getStoredRoomSettingsRowFromDatabase(database, jid);
const existingOwnerOverride = getStoredOwnerRoleOverride(database, jid);
if (!existing) {
insertRoom.run(
jid,
'single',
'inferred',
group.name,
group.folder,
group.trigger,
(group.requiresTrigger ?? true) ? 1 : 0,
(group.isMain ?? false) ? 1 : 0,
ownerAgentType,
group.workDir ?? null,
createdAt,
createdAt,
);
report.migratedRooms += 1;
report.migratedJsonRooms += 1;
}
if (
existingOwnerOverride &&
!jsonEntryMatchesStoredOwnerOverride(database, jid, group)
) {
throw new Error(
`Legacy registered_groups.json owner override conflicts with existing owner override for ${jid}`,
);
}
if (!existingOwnerOverride) {
upsertRoomRoleOverride(database, jid, {
role: 'owner',
agentType: ownerAgentType,
agentConfig: group.agentConfig,
createdAt,
updatedAt: createdAt,
});
report.migratedRoleOverrides += 1;
}
}
fs.renameSync(legacyJsonPath, `${legacyJsonPath}.migrated`);
report.renamedLegacyJson = true;
logger.info(
{ legacyJsonPath, projectRoot },
'Renamed registered_groups.json after explicit migration',
);
}
export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(STORE_DIR, { recursive: true });
const projectRoot = process.cwd();
const unexpectedDataStateFiles = listUnexpectedDataStateFiles(DATA_DIR);
if (unexpectedDataStateFiles.length > 0) {
throw new Error(
`Unexpected data state files detected before room migration (files=${unexpectedDataStateFiles.join(',')})`,
);
}
const database = openPersistentDatabase();
initializeDatabaseSchema(database);
@@ -343,16 +137,12 @@ export async function run(_args: string[]): Promise<void> {
migratedRooms: 0,
migratedRoleOverrides: 0,
skippedRooms: 0,
migratedJsonRooms: 0,
skippedJsonRooms: 0,
backedUpLegacyRows: 0,
renamedLegacyJson: false,
};
database.transaction(() => {
report.backedUpLegacyRows = backupPendingLegacyRows(database);
migrateLegacyRegisteredGroupsTable(database, report);
migrateLegacyRegisteredGroupsJson(database, projectRoot, report);
})();
database.close();
@@ -361,10 +151,7 @@ export async function run(_args: string[]): Promise<void> {
MIGRATED_ROOMS: report.migratedRooms,
MIGRATED_ROLE_OVERRIDES: report.migratedRoleOverrides,
SKIPPED_ROOMS: report.skippedRooms,
MIGRATED_JSON_ROOMS: report.migratedJsonRooms,
SKIPPED_JSON_ROOMS: report.skippedJsonRooms,
BACKED_UP_LEGACY_ROWS: report.backedUpLegacyRows,
RENAMED_LEGACY_JSON: report.renamedLegacyJson,
STATUS: 'success',
LOG: 'logs/setup.log',
});

View File

@@ -4,16 +4,16 @@ import path from 'path';
import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js';
import { listUnexpectedDataStateFiles } from '../src/data-state-files.js';
import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js';
export interface AssignedRoomsSummary {
assignedRooms: number;
roomsByOwnerAgent: Record<string, number>;
legacyRegisteredGroupRows: number;
hasLegacyRegisteredGroupsJson: boolean;
legacyRoomMigrationRequired: boolean;
pendingLegacyJsonStateFiles: string[];
legacyJsonStateMigrationRequired: boolean;
unexpectedDataStateFiles: string[];
unexpectedDataStateDetected: boolean;
}
export interface LegacyMigrationGuidance {
@@ -25,50 +25,36 @@ function getDataDir(projectRoot: string): string {
return process.env.EJCLAW_DATA_DIR || path.join(projectRoot, 'data');
}
function getPendingLegacyJsonStateFiles(projectRoot: string): string[] {
const dataDir = getDataDir(projectRoot);
return ['router_state.json', 'sessions.json'].filter((filename) =>
fs.existsSync(path.join(dataDir, filename)),
);
}
export function getLegacyMigrationGuidance(
summary: Pick<
AssignedRoomsSummary,
'legacyRoomMigrationRequired' | 'legacyJsonStateMigrationRequired'
'legacyRoomMigrationRequired' | 'unexpectedDataStateDetected'
>,
target: 'setup' | 'verification',
): LegacyMigrationGuidance | undefined {
if (
!summary.legacyRoomMigrationRequired &&
!summary.legacyJsonStateMigrationRequired
!summary.unexpectedDataStateDetected
) {
return undefined;
}
const steps: string[] = [];
if (summary.legacyRoomMigrationRequired) {
steps.push('bun setup/index.ts --step migrate-room-registrations');
}
if (summary.legacyJsonStateMigrationRequired) {
steps.push('bun setup/index.ts --step migrate-json-state');
}
const error =
summary.legacyRoomMigrationRequired &&
summary.legacyJsonStateMigrationRequired
? 'legacy_migration_required'
summary.legacyRoomMigrationRequired && summary.unexpectedDataStateDetected
? 'legacy_migration_and_unexpected_data_state_detected'
: summary.legacyRoomMigrationRequired
? 'legacy_room_migration_required'
: 'legacy_json_state_migration_required';
const commandText =
steps.length === 1
? `Run \`${steps[0]}\` before continuing with ${target}`
: `Run \`${steps[0]}\` and \`${steps[1]}\` before continuing with ${target}`;
: 'unexpected_data_state_files_detected';
const nextStep =
summary.legacyRoomMigrationRequired && summary.unexpectedDataStateDetected
? `Run \`bun setup/index.ts --step migrate-room-registrations\` and remove or archive unexpected data state files before continuing with ${target}`
: summary.legacyRoomMigrationRequired
? `Run \`bun setup/index.ts --step migrate-room-registrations\` before continuing with ${target}`
: `Remove or archive unexpected data state files before continuing with ${target}`;
return {
error,
nextStep: commandText,
nextStep,
};
}
@@ -127,23 +113,18 @@ export function detectRoomRegistrationState(
}
}
const hasLegacyRegisteredGroupsJson = fs.existsSync(
path.join(getDataDir(projectRoot), 'registered_groups.json'),
const unexpectedDataStateFiles = listUnexpectedDataStateFiles(
getDataDir(projectRoot),
);
const pendingLegacyJsonStateFiles =
getPendingLegacyJsonStateFiles(projectRoot);
const legacyRoomMigrationRequired =
legacyRegisteredGroupRows > 0 || hasLegacyRegisteredGroupsJson;
const legacyJsonStateMigrationRequired =
pendingLegacyJsonStateFiles.length > 0;
const legacyRoomMigrationRequired = legacyRegisteredGroupRows > 0;
const unexpectedDataStateDetected = unexpectedDataStateFiles.length > 0;
return {
assignedRooms,
roomsByOwnerAgent,
legacyRegisteredGroupRows,
hasLegacyRegisteredGroupsJson,
legacyRoomMigrationRequired,
pendingLegacyJsonStateFiles,
legacyJsonStateMigrationRequired,
unexpectedDataStateFiles,
unexpectedDataStateDetected,
};
}

View File

@@ -128,10 +128,9 @@ describe('verify state helpers', () => {
codex: 2,
},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
});
});
@@ -157,10 +156,9 @@ describe('verify state helpers', () => {
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 1,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: true,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
);
});
@@ -268,10 +266,9 @@ describe('verify state helpers', () => {
codex: 1,
},
legacyRegisteredGroupRows: 1,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: true,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
);
});
@@ -396,15 +393,14 @@ describe('verify state helpers', () => {
codex: 1,
},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
);
});
it('marks legacy registered_groups.json as migration-required', () => {
it('marks unexpected data-state files as setup blockers', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
tempRoots.push(tempRoot);
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
@@ -417,14 +413,13 @@ describe('verify state helpers', () => {
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: true,
legacyRoomMigrationRequired: true,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
legacyRoomMigrationRequired: false,
unexpectedDataStateFiles: ['registered_groups.json'],
unexpectedDataStateDetected: true,
});
});
it('marks pending legacy json state files as migration-required', () => {
it('marks unexpected router/session json files as setup blockers', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
tempRoots.push(tempRoot);
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
@@ -441,10 +436,9 @@ describe('verify state helpers', () => {
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: ['router_state.json', 'sessions.json'],
legacyJsonStateMigrationRequired: true,
unexpectedDataStateFiles: ['router_state.json', 'sessions.json'],
unexpectedDataStateDetected: true,
});
});
@@ -474,10 +468,9 @@ describe('verify state helpers', () => {
assignedRooms: 2,
roomsByOwnerAgent: { codex: 1 },
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
),
).toMatchObject({
@@ -502,10 +495,9 @@ describe('verify state helpers', () => {
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
),
).toMatchObject({
@@ -528,10 +520,9 @@ describe('verify state helpers', () => {
assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
),
).toMatchObject({
@@ -556,10 +547,9 @@ describe('verify state helpers', () => {
assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
{ tribunalRooms: 1 },
),
@@ -588,10 +578,9 @@ describe('verify state helpers', () => {
assignedRooms: 1,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
{ tribunalRooms: 1, activeArbiterTasks: 1 },
),
@@ -617,10 +606,9 @@ describe('verify state helpers', () => {
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 2,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: true,
pendingLegacyJsonStateFiles: [],
legacyJsonStateMigrationRequired: false,
unexpectedDataStateFiles: [],
unexpectedDataStateDetected: false,
},
),
).toMatchObject({
@@ -630,7 +618,7 @@ describe('verify state helpers', () => {
});
});
it('fails verification when legacy json state migration is still required', () => {
it('fails verification when unexpected data state files are still present', () => {
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
expect(
@@ -643,16 +631,15 @@ describe('verify state helpers', () => {
assignedRooms: 1,
roomsByOwnerAgent: { codex: 1 },
legacyRegisteredGroupRows: 0,
hasLegacyRegisteredGroupsJson: false,
legacyRoomMigrationRequired: false,
pendingLegacyJsonStateFiles: ['router_state.json'],
legacyJsonStateMigrationRequired: true,
unexpectedDataStateFiles: ['router_state.json'],
unexpectedDataStateDetected: true,
},
),
).toMatchObject({
status: 'failed',
legacyJsonStateMigrationRequired: true,
pendingLegacyJsonStateFiles: ['router_state.json'],
unexpectedDataStateDetected: true,
unexpectedDataStateFiles: ['router_state.json'],
});
});
});

View File

@@ -162,7 +162,7 @@ export function buildVerifySummary(
arbiterRoutingSatisfied &&
roomSummary.assignedRooms > 0 &&
!roomSummary.legacyRoomMigrationRequired &&
!roomSummary.legacyJsonStateMigrationRequired
!roomSummary.unexpectedDataStateDetected
? 'success'
: 'failed';

View File

@@ -95,13 +95,9 @@ export async function run(_args: string[]): Promise<void> {
ASSIGNED_ROOMS: roomSummary.assignedRooms,
ROOMS_BY_OWNER_AGENT: JSON.stringify(roomSummary.roomsByOwnerAgent),
LEGACY_REGISTERED_GROUP_ROWS: roomSummary.legacyRegisteredGroupRows,
HAS_LEGACY_REGISTERED_GROUPS_JSON:
roomSummary.hasLegacyRegisteredGroupsJson,
LEGACY_ROOM_MIGRATION_REQUIRED: roomSummary.legacyRoomMigrationRequired,
PENDING_LEGACY_JSON_STATE_FILES:
roomSummary.pendingLegacyJsonStateFiles.join(','),
LEGACY_JSON_STATE_MIGRATION_REQUIRED:
roomSummary.legacyJsonStateMigrationRequired,
UNEXPECTED_DATA_STATE_FILES: roomSummary.unexpectedDataStateFiles.join(','),
UNEXPECTED_DATA_STATE_DETECTED: roomSummary.unexpectedDataStateDetected,
REVIEWER_CHANNEL_CONFIGURED: reviewerChannelConfigured,
ARBITER_CHANNEL_CONFIGURED: arbiterChannelConfigured,
...(legacyMigrationGuidance

View File

@@ -108,7 +108,7 @@ describe('config/env loading', () => {
const { loadConfig } = await import('./config/load-config.js');
expect(() => loadConfig()).toThrow(
/Legacy env aliases are no longer supported; rename them to canonical keys \(DISCORD_BOT_TOKEN -> DISCORD_OWNER_BOT_TOKEN\)/,
/Legacy env aliases are no longer supported; remove or rename \(DISCORD_BOT_TOKEN\) to the canonical keys/,
);
});
@@ -118,7 +118,7 @@ describe('config/env loading', () => {
const { loadConfig } = await import('./config/load-config.js');
expect(() => loadConfig()).toThrow(
/Legacy env aliases are no longer supported; rename them to canonical keys \(SESSION_COMMAND_USER_IDS -> SESSION_COMMAND_ALLOWED_SENDERS\)/,
/Legacy env aliases are no longer supported; remove or rename \(SESSION_COMMAND_USER_IDS\) to the canonical keys/,
);
});
});

View File

@@ -3,19 +3,19 @@ import path from 'path';
import type { MoaConfig, MoaModelConfig } from '../moa.js';
import type { AgentType } from '../types.js';
import { getEnv } from '../env.js';
import { getEnv, listConfiguredEnvKeys } from '../env.js';
import type { AppConfig, RoleModelConfig } from './schema.js';
const LEGACY_ENV_ALIAS_MAP = {
DISCORD_BOT_TOKEN: 'DISCORD_OWNER_BOT_TOKEN',
DISCORD_CLAUDE_BOT_TOKEN: 'DISCORD_OWNER_BOT_TOKEN',
DISCORD_CODEX_BOT_TOKEN: 'DISCORD_REVIEWER_BOT_TOKEN',
DISCORD_CODEX_MAIN_BOT_TOKEN: 'DISCORD_REVIEWER_BOT_TOKEN',
DISCORD_REVIEW_BOT_TOKEN: 'DISCORD_ARBITER_BOT_TOKEN',
DISCORD_CODEX_REVIEW_BOT_TOKEN: 'DISCORD_ARBITER_BOT_TOKEN',
SESSION_COMMAND_USER_IDS: 'SESSION_COMMAND_ALLOWED_SENDERS',
} as const;
const CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS = new Set([
'DISCORD_OWNER_BOT_TOKEN',
'DISCORD_REVIEWER_BOT_TOKEN',
'DISCORD_ARBITER_BOT_TOKEN',
]);
const CANONICAL_SESSION_COMMAND_KEYS = new Set([
'SESSION_COMMAND_ALLOWED_SENDERS',
]);
function readText(key: string): string | undefined {
return getEnv(key);
@@ -111,22 +111,22 @@ function normalizeServiceId(
}
function assertNoLegacyEnvAliasesConfigured(): void {
const configuredAliases = Object.entries(LEGACY_ENV_ALIAS_MAP).filter(
([legacyKey]) => {
const value = getEnv(legacyKey);
return value != null && value !== '';
},
);
const configuredAliases = listConfiguredEnvKeys().filter((key) => {
if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) {
return !CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS.has(key);
}
if (key.startsWith('SESSION_COMMAND_')) {
return !CANONICAL_SESSION_COMMAND_KEYS.has(key);
}
return false;
});
if (configuredAliases.length === 0) {
return;
}
const aliasList = configuredAliases
.map(([legacyKey, canonicalKey]) => `${legacyKey} -> ${canonicalKey}`)
.join(', ');
throw new Error(
`Legacy env aliases are no longer supported; rename them to canonical keys (${aliasList})`,
`Legacy env aliases are no longer supported; remove or rename (${configuredAliases.join(', ')}) to the canonical keys DISCORD_OWNER_BOT_TOKEN, DISCORD_REVIEWER_BOT_TOKEN, DISCORD_ARBITER_BOT_TOKEN, SESSION_COMMAND_ALLOWED_SENDERS`,
);
}

27
src/data-state-files.ts Normal file
View File

@@ -0,0 +1,27 @@
import fs from 'fs';
import path from 'path';
const ALLOWED_DATA_JSON_PATTERNS = [
/^token-rotation-state\.json$/,
/^codex-rotation-state\.json$/,
/^claude-usage-cache\.json$/,
/^restart-context\..+\.json$/,
];
function isAllowedDataStateJson(filename: string): boolean {
return ALLOWED_DATA_JSON_PATTERNS.some((pattern) => pattern.test(filename));
}
export function listUnexpectedDataStateFiles(dataDir: string): string[] {
if (!fs.existsSync(dataDir)) {
return [];
}
return fs
.readdirSync(dataDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((filename) => path.extname(filename) === '.json')
.filter((filename) => !isAllowedDataStateJson(filename))
.sort();
}

View File

@@ -2482,7 +2482,7 @@ describe('room assignment writes', () => {
);
});
it('fails init when legacy router_state DB keys remain without canonical keys', () => {
it('fails init when unsupported router_state DB keys remain without canonical keys', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-router-state-db-');
const dbPath = path.join(tempDir, 'messages.db');
const legacyDb = new Database(dbPath);
@@ -2497,7 +2497,7 @@ describe('room assignment writes', () => {
legacyDb.close();
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
/Legacy router_state DB migration required before startup \(keys=last_agent_timestamp,last_timestamp\)/,
/Unsupported router_state DB keys remain before startup \(keys=last_agent_timestamp,last_timestamp\)/,
);
const rawDb = new Database(dbPath, { readonly: true });

View File

@@ -13,6 +13,7 @@ import {
SERVICE_ID,
SERVICE_SESSION_SCOPE,
} from './config.js';
import { listUnexpectedDataStateFiles } from './data-state-files.js';
import {
isValidGroupFolder,
resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup,
@@ -155,7 +156,7 @@ import {
} from './db/service-handoffs.js';
import {
getLastRespondingAgentTypeFromDatabase,
getLegacyRouterStateKeysFromDatabase,
getUnsupportedRouterStateKeysFromDatabase,
getRouterStateForServiceFromDatabase,
getRouterStateFromDatabase,
setRouterStateForServiceInDatabase,
@@ -242,8 +243,8 @@ export function initDatabase(): void {
db = openPersistentDatabase();
initializeDatabaseSchema(db);
assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyJsonStateMigration();
assertNoPendingLegacyRouterStateDbMigration();
assertNoUnexpectedDataStateFiles();
assertNoUnsupportedRouterStateDbKeys();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
@@ -267,7 +268,7 @@ export function _initTestDatabaseFromFile(dbPath: string): void {
db = openDatabaseFromFile(dbPath);
initializeDatabaseSchema(db);
assertNoPendingLegacyRoomMigration();
assertNoPendingLegacyRouterStateDbMigration();
assertNoUnsupportedRouterStateDbKeys();
clearPairedTaskExecutionLeasesForServiceInDatabase(
db,
normalizeServiceId(SERVICE_ID),
@@ -1001,39 +1002,34 @@ function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
function assertNoPendingLegacyRoomMigration(): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
const hasLegacyRegisteredGroupsJson = fs.existsSync(
path.join(DATA_DIR, 'registered_groups.json'),
);
if (pendingLegacyRows === 0 && !hasLegacyRegisteredGroupsJson) {
if (pendingLegacyRows === 0) {
return;
}
throw new Error(
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows}, legacy_json=${hasLegacyRegisteredGroupsJson})`,
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows})`,
);
}
function assertNoPendingLegacyJsonStateMigration(): void {
const pendingFiles = ['router_state.json', 'sessions.json'].filter(
(filename) => fs.existsSync(path.join(DATA_DIR, filename)),
);
function assertNoUnexpectedDataStateFiles(): void {
const pendingFiles = listUnexpectedDataStateFiles(DATA_DIR);
if (pendingFiles.length === 0) {
return;
}
throw new Error(
`Legacy JSON state migration required before startup (files=${pendingFiles.join(',')})`,
`Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`,
);
}
function assertNoPendingLegacyRouterStateDbMigration(): void {
const legacyKeys = getLegacyRouterStateKeysFromDatabase(db);
function assertNoUnsupportedRouterStateDbKeys(): void {
const legacyKeys = getUnsupportedRouterStateKeysFromDatabase(db);
if (legacyKeys.length === 0) {
return;
}
throw new Error(
`Legacy router_state DB migration required before startup (keys=${legacyKeys.join(',')})`,
`Unsupported router_state DB keys remain before startup (keys=${legacyKeys.join(',')})`,
);
}

View File

@@ -65,16 +65,19 @@ export function setRouterStateForServiceInDatabase(
.run(prefixedKey, value);
}
export function getLegacyRouterStateKeysFromDatabase(
export function getUnsupportedRouterStateKeysFromDatabase(
database: Database,
): string[] {
const rows = database
.prepare(
`SELECT key
FROM router_state
WHERE key IN ('last_timestamp', 'last_agent_timestamp')
OR key LIKE '%:last_timestamp'
OR key LIKE '%:last_agent_timestamp'
WHERE (
CASE
WHEN instr(key, ':') > 0 THEN substr(key, instr(key, ':') + 1)
ELSE key
END
) NOT IN ('last_seq', 'last_agent_seq')
ORDER BY key`,
)
.all() as Array<{ key: string }>;

View File

@@ -74,3 +74,11 @@ export function readEnvFile(keys: string[]): Record<string, string> {
}
return result;
}
export function listConfiguredEnvKeys(): string[] {
if (!_cache) loadEnvFile();
return Array.from(
new Set([...Object.keys(_cache!), ...Object.keys(process.env)]),
).sort();
}