diff --git a/setup/environment.test.ts b/setup/environment.test.ts index 320f6d0..39971f8 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -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); diff --git a/setup/environment.ts b/setup/environment.ts index b088d72..4f9e2c9 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -65,11 +65,9 @@ export async function run(_args: string[]): Promise { 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 { 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: diff --git a/setup/index.ts b/setup/index.ts index 6fd031e..91241dd 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -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'), diff --git a/setup/migrate-json-state.test.ts b/setup/migrate-json-state.test.ts deleted file mode 100644 index ba0d14e..0000000 --- a/setup/migrate-json-state.test.ts +++ /dev/null @@ -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', - }), - ); - }); -}); diff --git a/setup/migrate-json-state.ts b/setup/migrate-json-state.ts deleted file mode 100644 index 0ff381f..0000000 --- a/setup/migrate-json-state.ts +++ /dev/null @@ -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, -): { - 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; - last_agent_timestamp?: Record; - } | 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, -): { - migratedSessions: number; - renamed: boolean; -} { - const sessionsPath = path.join(DATA_DIR, 'sessions.json'); - const sessions = readJsonFile(sessionsPath) as Record | 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 { - 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', - }); -} diff --git a/setup/migrate-room-registrations.test.ts b/setup/migrate-room-registrations.test.ts index fc73dbf..52815bd 100644 --- a/setup/migrate-room-registrations.test.ts +++ b/setup/migrate-room-registrations.test.ts @@ -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/, + /Unexpected data state files detected before room migration/, ); - 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', - }), - ); - }); - - 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); }); }); diff --git a/setup/migrate-room-registrations.ts b/setup/migrate-room-registrations.ts index 0525e6e..8c379d6 100644 --- a/setup/migrate-room-registrations.ts +++ b/setup/migrate-room-registrations.ts @@ -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 { 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 { 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 { 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', }); diff --git a/setup/room-registration-state.ts b/setup/room-registration-state.ts index 55b8a5c..615e1a8 100644 --- a/setup/room-registration-state.ts +++ b/setup/room-registration-state.ts @@ -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; 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, }; } diff --git a/setup/verify-state.test.ts b/setup/verify-state.test.ts index 3e2ec32..ed89843 100644 --- a/setup/verify-state.test.ts +++ b/setup/verify-state.test.ts @@ -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'], }); }); }); diff --git a/setup/verify-state.ts b/setup/verify-state.ts index f950d3f..7057a65 100644 --- a/setup/verify-state.ts +++ b/setup/verify-state.ts @@ -162,7 +162,7 @@ export function buildVerifySummary( arbiterRoutingSatisfied && roomSummary.assignedRooms > 0 && !roomSummary.legacyRoomMigrationRequired && - !roomSummary.legacyJsonStateMigrationRequired + !roomSummary.unexpectedDataStateDetected ? 'success' : 'failed'; diff --git a/setup/verify.ts b/setup/verify.ts index e6963fc..b4fbf80 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -95,13 +95,9 @@ export async function run(_args: string[]): Promise { 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 diff --git a/src/config.test.ts b/src/config.test.ts index 47a6d56..e958ffd 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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/, ); }); }); diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 5e3e9f0..58b7d7a 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -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`, ); } diff --git a/src/data-state-files.ts b/src/data-state-files.ts new file mode 100644 index 0000000..420e416 --- /dev/null +++ b/src/data-state-files.ts @@ -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(); +} diff --git a/src/db.test.ts b/src/db.test.ts index de5c27c..2d5a9ab 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -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 }); diff --git a/src/db.ts b/src/db.ts index 98e3779..01fe3e2 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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(',')})`, ); } diff --git a/src/db/router-state.ts b/src/db/router-state.ts index a04fe04..ec51e65 100644 --- a/src/db/router-state.ts +++ b/src/db/router-state.ts @@ -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 }>; diff --git a/src/env.ts b/src/env.ts index f9e2205..9446170 100644 --- a/src/env.ts +++ b/src/env.ts @@ -74,3 +74,11 @@ export function readEnvFile(keys: string[]): Record { } return result; } + +export function listConfiguredEnvKeys(): string[] { + if (!_cache) loadEnvFile(); + + return Array.from( + new Set([...Object.keys(_cache!), ...Object.keys(process.env)]), + ).sort(); +}