refactor: remove legacy config and state aliases (PR5)
This commit is contained in:
@@ -169,7 +169,7 @@ describe('environment step legacy-room handling', () => {
|
|||||||
exitSpy.mockRestore();
|
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-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||||
tempDirs.push(tempDir);
|
tempDirs.push(tempDir);
|
||||||
process.chdir(tempDir);
|
process.chdir(tempDir);
|
||||||
|
|||||||
@@ -65,11 +65,9 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
hasAuth,
|
hasAuth,
|
||||||
assignedRooms: roomState.assignedRooms,
|
assignedRooms: roomState.assignedRooms,
|
||||||
legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows,
|
legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows,
|
||||||
hasLegacyRegisteredGroupsJson: roomState.hasLegacyRegisteredGroupsJson,
|
|
||||||
legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired,
|
legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired,
|
||||||
pendingLegacyJsonStateFiles: roomState.pendingLegacyJsonStateFiles,
|
unexpectedDataStateFiles: roomState.unexpectedDataStateFiles,
|
||||||
legacyJsonStateMigrationRequired:
|
unexpectedDataStateDetected: roomState.unexpectedDataStateDetected,
|
||||||
roomState.legacyJsonStateMigrationRequired,
|
|
||||||
hasBubblewrap,
|
hasBubblewrap,
|
||||||
hasSocat,
|
hasSocat,
|
||||||
apparmorRestrictUnprivilegedUserns,
|
apparmorRestrictUnprivilegedUserns,
|
||||||
@@ -87,12 +85,9 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
HAS_ASSIGNED_ROOMS: roomState.assignedRooms > 0,
|
HAS_ASSIGNED_ROOMS: roomState.assignedRooms > 0,
|
||||||
ASSIGNED_ROOMS: roomState.assignedRooms,
|
ASSIGNED_ROOMS: roomState.assignedRooms,
|
||||||
LEGACY_REGISTERED_GROUP_ROWS: roomState.legacyRegisteredGroupRows,
|
LEGACY_REGISTERED_GROUP_ROWS: roomState.legacyRegisteredGroupRows,
|
||||||
HAS_LEGACY_REGISTERED_GROUPS_JSON: roomState.hasLegacyRegisteredGroupsJson,
|
|
||||||
LEGACY_ROOM_MIGRATION_REQUIRED: roomState.legacyRoomMigrationRequired,
|
LEGACY_ROOM_MIGRATION_REQUIRED: roomState.legacyRoomMigrationRequired,
|
||||||
PENDING_LEGACY_JSON_STATE_FILES:
|
UNEXPECTED_DATA_STATE_FILES: roomState.unexpectedDataStateFiles.join(','),
|
||||||
roomState.pendingLegacyJsonStateFiles.join(','),
|
UNEXPECTED_DATA_STATE_DETECTED: roomState.unexpectedDataStateDetected,
|
||||||
LEGACY_JSON_STATE_MIGRATION_REQUIRED:
|
|
||||||
roomState.legacyJsonStateMigrationRequired,
|
|
||||||
HAS_BWRAP: hasBubblewrap,
|
HAS_BWRAP: hasBubblewrap,
|
||||||
HAS_SOCAT: hasSocat,
|
HAS_SOCAT: hasSocat,
|
||||||
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
|
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const STEPS: Record<
|
|||||||
runners: () => import('./runners.js'),
|
runners: () => import('./runners.js'),
|
||||||
groups: () => import('./groups.js'),
|
groups: () => import('./groups.js'),
|
||||||
register: () => import('./register.js'),
|
register: () => import('./register.js'),
|
||||||
'migrate-json-state': () => import('./migrate-json-state.js'),
|
|
||||||
'migrate-room-registrations': () => import('./migrate-room-registrations.js'),
|
'migrate-room-registrations': () => import('./migrate-room-registrations.js'),
|
||||||
'restart-stack': () => import('./restart-stack.js'),
|
'restart-stack': () => import('./restart-stack.js'),
|
||||||
service: () => import('./service.js'),
|
service: () => import('./service.js'),
|
||||||
|
|||||||
@@ -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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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(
|
const tempRoot = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-'),
|
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-'),
|
||||||
);
|
);
|
||||||
@@ -103,31 +103,10 @@ describe('migrate room registrations step', () => {
|
|||||||
);
|
);
|
||||||
legacyDb.close();
|
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();
|
const emitStatusMock = vi.fn();
|
||||||
vi.doMock('./status.js', () => ({
|
vi.doMock('./status.js', () => ({
|
||||||
emitStatus: emitStatusMock,
|
emitStatus: emitStatusMock,
|
||||||
}));
|
}));
|
||||||
vi.doMock('../src/logger.js', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { run } = await import('./migrate-room-registrations.js');
|
const { run } = await import('./migrate-room-registrations.js');
|
||||||
|
|
||||||
@@ -143,12 +122,6 @@ describe('migrate room registrations step', () => {
|
|||||||
)
|
)
|
||||||
.all(),
|
.all(),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
|
||||||
chat_jid: 'dc:json-room',
|
|
||||||
room_mode: 'single',
|
|
||||||
mode_source: 'inferred',
|
|
||||||
owner_agent_type: 'claude-code',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
chat_jid: 'dc:legacy-room',
|
chat_jid: 'dc:legacy-room',
|
||||||
room_mode: 'tribunal',
|
room_mode: 'tribunal',
|
||||||
@@ -165,11 +138,6 @@ describe('migrate room registrations step', () => {
|
|||||||
)
|
)
|
||||||
.all(),
|
.all(),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
|
||||||
chat_jid: 'dc:json-room',
|
|
||||||
role: 'owner',
|
|
||||||
agent_type: 'claude-code',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
chat_jid: 'dc:legacy-room',
|
chat_jid: 'dc:legacy-room',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
@@ -201,22 +169,13 @@ describe('migrate room registrations step', () => {
|
|||||||
]);
|
]);
|
||||||
migratedDb.close();
|
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(
|
expect(emitStatusMock).toHaveBeenLastCalledWith(
|
||||||
'MIGRATE_ROOM_REGISTRATIONS',
|
'MIGRATE_ROOM_REGISTRATIONS',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
MIGRATED_ROOMS: 2,
|
MIGRATED_ROOMS: 1,
|
||||||
MIGRATED_ROLE_OVERRIDES: 3,
|
MIGRATED_ROLE_OVERRIDES: 2,
|
||||||
SKIPPED_ROOMS: 0,
|
SKIPPED_ROOMS: 0,
|
||||||
MIGRATED_JSON_ROOMS: 1,
|
|
||||||
SKIPPED_JSON_ROOMS: 0,
|
|
||||||
BACKED_UP_LEGACY_ROWS: 2,
|
BACKED_UP_LEGACY_ROWS: 2,
|
||||||
RENAMED_LEGACY_JSON: true,
|
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -229,10 +188,7 @@ describe('migrate room registrations step', () => {
|
|||||||
MIGRATED_ROOMS: 0,
|
MIGRATED_ROOMS: 0,
|
||||||
MIGRATED_ROLE_OVERRIDES: 0,
|
MIGRATED_ROLE_OVERRIDES: 0,
|
||||||
SKIPPED_ROOMS: 0,
|
SKIPPED_ROOMS: 0,
|
||||||
MIGRATED_JSON_ROOMS: 0,
|
|
||||||
SKIPPED_JSON_ROOMS: 0,
|
|
||||||
BACKED_UP_LEGACY_ROWS: 0,
|
BACKED_UP_LEGACY_ROWS: 0,
|
||||||
RENAMED_LEGACY_JSON: false,
|
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -370,12 +326,6 @@ describe('migrate room registrations step', () => {
|
|||||||
vi.doMock('./status.js', () => ({
|
vi.doMock('./status.js', () => ({
|
||||||
emitStatus: emitStatusMock,
|
emitStatus: emitStatusMock,
|
||||||
}));
|
}));
|
||||||
vi.doMock('../src/logger.js', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { run } = await import('./migrate-room-registrations.js');
|
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(
|
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);
|
tempRoots.push(tempRoot);
|
||||||
const storeDir = path.join(tempRoot, 'store');
|
const storeDir = path.join(tempRoot, 'store');
|
||||||
@@ -429,298 +379,12 @@ describe('migrate room registrations step', () => {
|
|||||||
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
|
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
|
||||||
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
|
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(path.join(dataDir, 'registered_groups.json'), '{}');
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { run } = await import('./migrate-room-registrations.js');
|
const { run } = await import('./migrate-room-registrations.js');
|
||||||
|
|
||||||
await expect(run([])).rejects.toThrow(
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import { DATA_DIR, STORE_DIR } from '../src/config.js';
|
import { DATA_DIR, STORE_DIR } from '../src/config.js';
|
||||||
|
import { listUnexpectedDataStateFiles } from '../src/data-state-files.js';
|
||||||
import {
|
import {
|
||||||
initializeDatabaseSchema,
|
initializeDatabaseSchema,
|
||||||
openPersistentDatabase,
|
openPersistentDatabase,
|
||||||
@@ -11,112 +11,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildLegacyRoomMigrationPlan,
|
buildLegacyRoomMigrationPlan,
|
||||||
getPendingLegacyRegisteredGroupJids,
|
getPendingLegacyRegisteredGroupJids,
|
||||||
getStoredRoomSettingsRowFromDatabase,
|
|
||||||
insertStoredRoomSettingsFromMigration,
|
insertStoredRoomSettingsFromMigration,
|
||||||
normalizeStoredAgentType,
|
|
||||||
upsertRoomRoleOverride,
|
upsertRoomRoleOverride,
|
||||||
} from '../src/db/room-registration.js';
|
} 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';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
interface MigrationReport {
|
interface MigrationReport {
|
||||||
migratedRooms: number;
|
migratedRooms: number;
|
||||||
migratedRoleOverrides: number;
|
migratedRoleOverrides: number;
|
||||||
skippedRooms: number;
|
skippedRooms: number;
|
||||||
migratedJsonRooms: number;
|
|
||||||
skippedJsonRooms: number;
|
|
||||||
backedUpLegacyRows: 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 {
|
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> {
|
export async function run(_args: string[]): Promise<void> {
|
||||||
fs.mkdirSync(STORE_DIR, { recursive: true });
|
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();
|
const database = openPersistentDatabase();
|
||||||
initializeDatabaseSchema(database);
|
initializeDatabaseSchema(database);
|
||||||
|
|
||||||
@@ -343,16 +137,12 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
migratedRooms: 0,
|
migratedRooms: 0,
|
||||||
migratedRoleOverrides: 0,
|
migratedRoleOverrides: 0,
|
||||||
skippedRooms: 0,
|
skippedRooms: 0,
|
||||||
migratedJsonRooms: 0,
|
|
||||||
skippedJsonRooms: 0,
|
|
||||||
backedUpLegacyRows: 0,
|
backedUpLegacyRows: 0,
|
||||||
renamedLegacyJson: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
database.transaction(() => {
|
database.transaction(() => {
|
||||||
report.backedUpLegacyRows = backupPendingLegacyRows(database);
|
report.backedUpLegacyRows = backupPendingLegacyRows(database);
|
||||||
migrateLegacyRegisteredGroupsTable(database, report);
|
migrateLegacyRegisteredGroupsTable(database, report);
|
||||||
migrateLegacyRegisteredGroupsJson(database, projectRoot, report);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
database.close();
|
database.close();
|
||||||
@@ -361,10 +151,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
MIGRATED_ROOMS: report.migratedRooms,
|
MIGRATED_ROOMS: report.migratedRooms,
|
||||||
MIGRATED_ROLE_OVERRIDES: report.migratedRoleOverrides,
|
MIGRATED_ROLE_OVERRIDES: report.migratedRoleOverrides,
|
||||||
SKIPPED_ROOMS: report.skippedRooms,
|
SKIPPED_ROOMS: report.skippedRooms,
|
||||||
MIGRATED_JSON_ROOMS: report.migratedJsonRooms,
|
|
||||||
SKIPPED_JSON_ROOMS: report.skippedJsonRooms,
|
|
||||||
BACKED_UP_LEGACY_ROWS: report.backedUpLegacyRows,
|
BACKED_UP_LEGACY_ROWS: report.backedUpLegacyRows,
|
||||||
RENAMED_LEGACY_JSON: report.renamedLegacyJson,
|
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import path from 'path';
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import { STORE_DIR } from '../src/config.js';
|
import { STORE_DIR } from '../src/config.js';
|
||||||
|
import { listUnexpectedDataStateFiles } from '../src/data-state-files.js';
|
||||||
import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js';
|
import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js';
|
||||||
|
|
||||||
export interface AssignedRoomsSummary {
|
export interface AssignedRoomsSummary {
|
||||||
assignedRooms: number;
|
assignedRooms: number;
|
||||||
roomsByOwnerAgent: Record<string, number>;
|
roomsByOwnerAgent: Record<string, number>;
|
||||||
legacyRegisteredGroupRows: number;
|
legacyRegisteredGroupRows: number;
|
||||||
hasLegacyRegisteredGroupsJson: boolean;
|
|
||||||
legacyRoomMigrationRequired: boolean;
|
legacyRoomMigrationRequired: boolean;
|
||||||
pendingLegacyJsonStateFiles: string[];
|
unexpectedDataStateFiles: string[];
|
||||||
legacyJsonStateMigrationRequired: boolean;
|
unexpectedDataStateDetected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyMigrationGuidance {
|
export interface LegacyMigrationGuidance {
|
||||||
@@ -25,50 +25,36 @@ function getDataDir(projectRoot: string): string {
|
|||||||
return process.env.EJCLAW_DATA_DIR || path.join(projectRoot, 'data');
|
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(
|
export function getLegacyMigrationGuidance(
|
||||||
summary: Pick<
|
summary: Pick<
|
||||||
AssignedRoomsSummary,
|
AssignedRoomsSummary,
|
||||||
'legacyRoomMigrationRequired' | 'legacyJsonStateMigrationRequired'
|
'legacyRoomMigrationRequired' | 'unexpectedDataStateDetected'
|
||||||
>,
|
>,
|
||||||
target: 'setup' | 'verification',
|
target: 'setup' | 'verification',
|
||||||
): LegacyMigrationGuidance | undefined {
|
): LegacyMigrationGuidance | undefined {
|
||||||
if (
|
if (
|
||||||
!summary.legacyRoomMigrationRequired &&
|
!summary.legacyRoomMigrationRequired &&
|
||||||
!summary.legacyJsonStateMigrationRequired
|
!summary.unexpectedDataStateDetected
|
||||||
) {
|
) {
|
||||||
return undefined;
|
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 =
|
const error =
|
||||||
summary.legacyRoomMigrationRequired &&
|
summary.legacyRoomMigrationRequired && summary.unexpectedDataStateDetected
|
||||||
summary.legacyJsonStateMigrationRequired
|
? 'legacy_migration_and_unexpected_data_state_detected'
|
||||||
? 'legacy_migration_required'
|
|
||||||
: summary.legacyRoomMigrationRequired
|
: summary.legacyRoomMigrationRequired
|
||||||
? 'legacy_room_migration_required'
|
? 'legacy_room_migration_required'
|
||||||
: 'legacy_json_state_migration_required';
|
: 'unexpected_data_state_files_detected';
|
||||||
const commandText =
|
const nextStep =
|
||||||
steps.length === 1
|
summary.legacyRoomMigrationRequired && summary.unexpectedDataStateDetected
|
||||||
? `Run \`${steps[0]}\` before continuing with ${target}`
|
? `Run \`bun setup/index.ts --step migrate-room-registrations\` and remove or archive unexpected data state files before continuing with ${target}`
|
||||||
: `Run \`${steps[0]}\` and \`${steps[1]}\` 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 {
|
return {
|
||||||
error,
|
error,
|
||||||
nextStep: commandText,
|
nextStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,23 +113,18 @@ export function detectRoomRegistrationState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLegacyRegisteredGroupsJson = fs.existsSync(
|
const unexpectedDataStateFiles = listUnexpectedDataStateFiles(
|
||||||
path.join(getDataDir(projectRoot), 'registered_groups.json'),
|
getDataDir(projectRoot),
|
||||||
);
|
);
|
||||||
const pendingLegacyJsonStateFiles =
|
const legacyRoomMigrationRequired = legacyRegisteredGroupRows > 0;
|
||||||
getPendingLegacyJsonStateFiles(projectRoot);
|
const unexpectedDataStateDetected = unexpectedDataStateFiles.length > 0;
|
||||||
const legacyRoomMigrationRequired =
|
|
||||||
legacyRegisteredGroupRows > 0 || hasLegacyRegisteredGroupsJson;
|
|
||||||
const legacyJsonStateMigrationRequired =
|
|
||||||
pendingLegacyJsonStateFiles.length > 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assignedRooms,
|
assignedRooms,
|
||||||
roomsByOwnerAgent,
|
roomsByOwnerAgent,
|
||||||
legacyRegisteredGroupRows,
|
legacyRegisteredGroupRows,
|
||||||
hasLegacyRegisteredGroupsJson,
|
|
||||||
legacyRoomMigrationRequired,
|
legacyRoomMigrationRequired,
|
||||||
pendingLegacyJsonStateFiles,
|
unexpectedDataStateFiles,
|
||||||
legacyJsonStateMigrationRequired,
|
unexpectedDataStateDetected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,10 +128,9 @@ describe('verify state helpers', () => {
|
|||||||
codex: 2,
|
codex: 2,
|
||||||
},
|
},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,10 +156,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 0,
|
assignedRooms: 0,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 1,
|
legacyRegisteredGroupRows: 1,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: true,
|
legacyRoomMigrationRequired: true,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -268,10 +266,9 @@ describe('verify state helpers', () => {
|
|||||||
codex: 1,
|
codex: 1,
|
||||||
},
|
},
|
||||||
legacyRegisteredGroupRows: 1,
|
legacyRegisteredGroupRows: 1,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: true,
|
legacyRoomMigrationRequired: true,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -396,15 +393,14 @@ describe('verify state helpers', () => {
|
|||||||
codex: 1,
|
codex: 1,
|
||||||
},
|
},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
||||||
@@ -417,14 +413,13 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 0,
|
assignedRooms: 0,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: true,
|
legacyRoomMigrationRequired: false,
|
||||||
legacyRoomMigrationRequired: true,
|
unexpectedDataStateFiles: ['registered_groups.json'],
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateDetected: true,
|
||||||
legacyJsonStateMigrationRequired: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
||||||
@@ -441,10 +436,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 0,
|
assignedRooms: 0,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: ['router_state.json', 'sessions.json'],
|
unexpectedDataStateFiles: ['router_state.json', 'sessions.json'],
|
||||||
legacyJsonStateMigrationRequired: true,
|
unexpectedDataStateDetected: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -474,10 +468,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 2,
|
assignedRooms: 2,
|
||||||
roomsByOwnerAgent: { codex: 1 },
|
roomsByOwnerAgent: { codex: 1 },
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -502,10 +495,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 0,
|
assignedRooms: 0,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -528,10 +520,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 1,
|
assignedRooms: 1,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -556,10 +547,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 1,
|
assignedRooms: 1,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
{ tribunalRooms: 1 },
|
{ tribunalRooms: 1 },
|
||||||
),
|
),
|
||||||
@@ -588,10 +578,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 1,
|
assignedRooms: 1,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
{ tribunalRooms: 1, activeArbiterTasks: 1 },
|
{ tribunalRooms: 1, activeArbiterTasks: 1 },
|
||||||
),
|
),
|
||||||
@@ -617,10 +606,9 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 0,
|
assignedRooms: 0,
|
||||||
roomsByOwnerAgent: {},
|
roomsByOwnerAgent: {},
|
||||||
legacyRegisteredGroupRows: 2,
|
legacyRegisteredGroupRows: 2,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: true,
|
legacyRoomMigrationRequired: true,
|
||||||
pendingLegacyJsonStateFiles: [],
|
unexpectedDataStateFiles: [],
|
||||||
legacyJsonStateMigrationRequired: false,
|
unexpectedDataStateDetected: false,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).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' }];
|
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -643,16 +631,15 @@ describe('verify state helpers', () => {
|
|||||||
assignedRooms: 1,
|
assignedRooms: 1,
|
||||||
roomsByOwnerAgent: { codex: 1 },
|
roomsByOwnerAgent: { codex: 1 },
|
||||||
legacyRegisteredGroupRows: 0,
|
legacyRegisteredGroupRows: 0,
|
||||||
hasLegacyRegisteredGroupsJson: false,
|
|
||||||
legacyRoomMigrationRequired: false,
|
legacyRoomMigrationRequired: false,
|
||||||
pendingLegacyJsonStateFiles: ['router_state.json'],
|
unexpectedDataStateFiles: ['router_state.json'],
|
||||||
legacyJsonStateMigrationRequired: true,
|
unexpectedDataStateDetected: true,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
legacyJsonStateMigrationRequired: true,
|
unexpectedDataStateDetected: true,
|
||||||
pendingLegacyJsonStateFiles: ['router_state.json'],
|
unexpectedDataStateFiles: ['router_state.json'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function buildVerifySummary(
|
|||||||
arbiterRoutingSatisfied &&
|
arbiterRoutingSatisfied &&
|
||||||
roomSummary.assignedRooms > 0 &&
|
roomSummary.assignedRooms > 0 &&
|
||||||
!roomSummary.legacyRoomMigrationRequired &&
|
!roomSummary.legacyRoomMigrationRequired &&
|
||||||
!roomSummary.legacyJsonStateMigrationRequired
|
!roomSummary.unexpectedDataStateDetected
|
||||||
? 'success'
|
? 'success'
|
||||||
: 'failed';
|
: 'failed';
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,9 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
ASSIGNED_ROOMS: roomSummary.assignedRooms,
|
ASSIGNED_ROOMS: roomSummary.assignedRooms,
|
||||||
ROOMS_BY_OWNER_AGENT: JSON.stringify(roomSummary.roomsByOwnerAgent),
|
ROOMS_BY_OWNER_AGENT: JSON.stringify(roomSummary.roomsByOwnerAgent),
|
||||||
LEGACY_REGISTERED_GROUP_ROWS: roomSummary.legacyRegisteredGroupRows,
|
LEGACY_REGISTERED_GROUP_ROWS: roomSummary.legacyRegisteredGroupRows,
|
||||||
HAS_LEGACY_REGISTERED_GROUPS_JSON:
|
|
||||||
roomSummary.hasLegacyRegisteredGroupsJson,
|
|
||||||
LEGACY_ROOM_MIGRATION_REQUIRED: roomSummary.legacyRoomMigrationRequired,
|
LEGACY_ROOM_MIGRATION_REQUIRED: roomSummary.legacyRoomMigrationRequired,
|
||||||
PENDING_LEGACY_JSON_STATE_FILES:
|
UNEXPECTED_DATA_STATE_FILES: roomSummary.unexpectedDataStateFiles.join(','),
|
||||||
roomSummary.pendingLegacyJsonStateFiles.join(','),
|
UNEXPECTED_DATA_STATE_DETECTED: roomSummary.unexpectedDataStateDetected,
|
||||||
LEGACY_JSON_STATE_MIGRATION_REQUIRED:
|
|
||||||
roomSummary.legacyJsonStateMigrationRequired,
|
|
||||||
REVIEWER_CHANNEL_CONFIGURED: reviewerChannelConfigured,
|
REVIEWER_CHANNEL_CONFIGURED: reviewerChannelConfigured,
|
||||||
ARBITER_CHANNEL_CONFIGURED: arbiterChannelConfigured,
|
ARBITER_CHANNEL_CONFIGURED: arbiterChannelConfigured,
|
||||||
...(legacyMigrationGuidance
|
...(legacyMigrationGuidance
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('config/env loading', () => {
|
|||||||
const { loadConfig } = await import('./config/load-config.js');
|
const { loadConfig } = await import('./config/load-config.js');
|
||||||
|
|
||||||
expect(() => loadConfig()).toThrow(
|
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');
|
const { loadConfig } = await import('./config/load-config.js');
|
||||||
|
|
||||||
expect(() => loadConfig()).toThrow(
|
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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import path from 'path';
|
|||||||
|
|
||||||
import type { MoaConfig, MoaModelConfig } from '../moa.js';
|
import type { MoaConfig, MoaModelConfig } from '../moa.js';
|
||||||
import type { AgentType } from '../types.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';
|
import type { AppConfig, RoleModelConfig } from './schema.js';
|
||||||
|
|
||||||
const LEGACY_ENV_ALIAS_MAP = {
|
const CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS = new Set([
|
||||||
DISCORD_BOT_TOKEN: 'DISCORD_OWNER_BOT_TOKEN',
|
'DISCORD_OWNER_BOT_TOKEN',
|
||||||
DISCORD_CLAUDE_BOT_TOKEN: 'DISCORD_OWNER_BOT_TOKEN',
|
'DISCORD_REVIEWER_BOT_TOKEN',
|
||||||
DISCORD_CODEX_BOT_TOKEN: 'DISCORD_REVIEWER_BOT_TOKEN',
|
'DISCORD_ARBITER_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',
|
const CANONICAL_SESSION_COMMAND_KEYS = new Set([
|
||||||
SESSION_COMMAND_USER_IDS: 'SESSION_COMMAND_ALLOWED_SENDERS',
|
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||||
} as const;
|
]);
|
||||||
|
|
||||||
function readText(key: string): string | undefined {
|
function readText(key: string): string | undefined {
|
||||||
return getEnv(key);
|
return getEnv(key);
|
||||||
@@ -111,22 +111,22 @@ function normalizeServiceId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertNoLegacyEnvAliasesConfigured(): void {
|
function assertNoLegacyEnvAliasesConfigured(): void {
|
||||||
const configuredAliases = Object.entries(LEGACY_ENV_ALIAS_MAP).filter(
|
const configuredAliases = listConfiguredEnvKeys().filter((key) => {
|
||||||
([legacyKey]) => {
|
if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) {
|
||||||
const value = getEnv(legacyKey);
|
return !CANONICAL_DISCORD_CHANNEL_TOKEN_KEYS.has(key);
|
||||||
return value != null && value !== '';
|
}
|
||||||
},
|
if (key.startsWith('SESSION_COMMAND_')) {
|
||||||
);
|
return !CANONICAL_SESSION_COMMAND_KEYS.has(key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (configuredAliases.length === 0) {
|
if (configuredAliases.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliasList = configuredAliases
|
|
||||||
.map(([legacyKey, canonicalKey]) => `${legacyKey} -> ${canonicalKey}`)
|
|
||||||
.join(', ');
|
|
||||||
throw new Error(
|
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
27
src/data-state-files.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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 tempDir = fs.mkdtempSync('/tmp/ejclaw-legacy-router-state-db-');
|
||||||
const dbPath = path.join(tempDir, 'messages.db');
|
const dbPath = path.join(tempDir, 'messages.db');
|
||||||
const legacyDb = new Database(dbPath);
|
const legacyDb = new Database(dbPath);
|
||||||
@@ -2497,7 +2497,7 @@ describe('room assignment writes', () => {
|
|||||||
legacyDb.close();
|
legacyDb.close();
|
||||||
|
|
||||||
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
|
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 });
|
const rawDb = new Database(dbPath, { readonly: true });
|
||||||
|
|||||||
30
src/db.ts
30
src/db.ts
@@ -13,6 +13,7 @@ import {
|
|||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
import { listUnexpectedDataStateFiles } from './data-state-files.js';
|
||||||
import {
|
import {
|
||||||
isValidGroupFolder,
|
isValidGroupFolder,
|
||||||
resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup,
|
resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup,
|
||||||
@@ -155,7 +156,7 @@ import {
|
|||||||
} from './db/service-handoffs.js';
|
} from './db/service-handoffs.js';
|
||||||
import {
|
import {
|
||||||
getLastRespondingAgentTypeFromDatabase,
|
getLastRespondingAgentTypeFromDatabase,
|
||||||
getLegacyRouterStateKeysFromDatabase,
|
getUnsupportedRouterStateKeysFromDatabase,
|
||||||
getRouterStateForServiceFromDatabase,
|
getRouterStateForServiceFromDatabase,
|
||||||
getRouterStateFromDatabase,
|
getRouterStateFromDatabase,
|
||||||
setRouterStateForServiceInDatabase,
|
setRouterStateForServiceInDatabase,
|
||||||
@@ -242,8 +243,8 @@ export function initDatabase(): void {
|
|||||||
db = openPersistentDatabase();
|
db = openPersistentDatabase();
|
||||||
initializeDatabaseSchema(db);
|
initializeDatabaseSchema(db);
|
||||||
assertNoPendingLegacyRoomMigration();
|
assertNoPendingLegacyRoomMigration();
|
||||||
assertNoPendingLegacyJsonStateMigration();
|
assertNoUnexpectedDataStateFiles();
|
||||||
assertNoPendingLegacyRouterStateDbMigration();
|
assertNoUnsupportedRouterStateDbKeys();
|
||||||
clearPairedTaskExecutionLeasesForServiceInDatabase(
|
clearPairedTaskExecutionLeasesForServiceInDatabase(
|
||||||
db,
|
db,
|
||||||
normalizeServiceId(SERVICE_ID),
|
normalizeServiceId(SERVICE_ID),
|
||||||
@@ -267,7 +268,7 @@ export function _initTestDatabaseFromFile(dbPath: string): void {
|
|||||||
db = openDatabaseFromFile(dbPath);
|
db = openDatabaseFromFile(dbPath);
|
||||||
initializeDatabaseSchema(db);
|
initializeDatabaseSchema(db);
|
||||||
assertNoPendingLegacyRoomMigration();
|
assertNoPendingLegacyRoomMigration();
|
||||||
assertNoPendingLegacyRouterStateDbMigration();
|
assertNoUnsupportedRouterStateDbKeys();
|
||||||
clearPairedTaskExecutionLeasesForServiceInDatabase(
|
clearPairedTaskExecutionLeasesForServiceInDatabase(
|
||||||
db,
|
db,
|
||||||
normalizeServiceId(SERVICE_ID),
|
normalizeServiceId(SERVICE_ID),
|
||||||
@@ -1001,39 +1002,34 @@ function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
|
|||||||
|
|
||||||
function assertNoPendingLegacyRoomMigration(): void {
|
function assertNoPendingLegacyRoomMigration(): void {
|
||||||
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
|
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
|
||||||
const hasLegacyRegisteredGroupsJson = fs.existsSync(
|
if (pendingLegacyRows === 0) {
|
||||||
path.join(DATA_DIR, 'registered_groups.json'),
|
|
||||||
);
|
|
||||||
if (pendingLegacyRows === 0 && !hasLegacyRegisteredGroupsJson) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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 {
|
function assertNoUnexpectedDataStateFiles(): void {
|
||||||
const pendingFiles = ['router_state.json', 'sessions.json'].filter(
|
const pendingFiles = listUnexpectedDataStateFiles(DATA_DIR);
|
||||||
(filename) => fs.existsSync(path.join(DATA_DIR, filename)),
|
|
||||||
);
|
|
||||||
if (pendingFiles.length === 0) {
|
if (pendingFiles.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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 {
|
function assertNoUnsupportedRouterStateDbKeys(): void {
|
||||||
const legacyKeys = getLegacyRouterStateKeysFromDatabase(db);
|
const legacyKeys = getUnsupportedRouterStateKeysFromDatabase(db);
|
||||||
if (legacyKeys.length === 0) {
|
if (legacyKeys.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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(',')})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,16 +65,19 @@ export function setRouterStateForServiceInDatabase(
|
|||||||
.run(prefixedKey, value);
|
.run(prefixedKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegacyRouterStateKeysFromDatabase(
|
export function getUnsupportedRouterStateKeysFromDatabase(
|
||||||
database: Database,
|
database: Database,
|
||||||
): string[] {
|
): string[] {
|
||||||
const rows = database
|
const rows = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT key
|
`SELECT key
|
||||||
FROM router_state
|
FROM router_state
|
||||||
WHERE key IN ('last_timestamp', 'last_agent_timestamp')
|
WHERE (
|
||||||
OR key LIKE '%:last_timestamp'
|
CASE
|
||||||
OR key LIKE '%:last_agent_timestamp'
|
WHEN instr(key, ':') > 0 THEN substr(key, instr(key, ':') + 1)
|
||||||
|
ELSE key
|
||||||
|
END
|
||||||
|
) NOT IN ('last_seq', 'last_agent_seq')
|
||||||
ORDER BY key`,
|
ORDER BY key`,
|
||||||
)
|
)
|
||||||
.all() as Array<{ key: string }>;
|
.all() as Array<{ key: string }>;
|
||||||
|
|||||||
@@ -74,3 +74,11 @@ export function readEnvFile(keys: string[]): Record<string, string> {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listConfiguredEnvKeys(): string[] {
|
||||||
|
if (!_cache) loadEnvFile();
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set([...Object.keys(_cache!), ...Object.keys(process.env)]),
|
||||||
|
).sort();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user