Finalize turn-role invariants and legacy migration cutover
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
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';
|
||||
import { detectAssignedRooms, run as runEnvironment } from './environment.js';
|
||||
|
||||
/**
|
||||
* Tests for the environment check step.
|
||||
@@ -17,58 +20,174 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database;
|
||||
describe('assigned rooms detection', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
it('returns false when the database does not exist', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
expect(
|
||||
detectAssignedRooms({ dbPath: path.join(tempDir, 'messages.db') }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns correct count after inserts', () => {
|
||||
it('returns false for an empty room_settings table', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||
tempDirs.push(tempDir);
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`CREATE TABLE room_settings (chat_jid TEXT PRIMARY KEY)`);
|
||||
db.close();
|
||||
|
||||
expect(detectAssignedRooms({ dbPath })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when canonical room assignments exist', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||
tempDirs.push(tempDir);
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
`INSERT INTO room_settings (chat_jid, room_mode, updated_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
).run('dc:room-1', 'single', '2024-01-01T00:00:00.000Z');
|
||||
db.close();
|
||||
|
||||
expect(detectAssignedRooms({ dbPath })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when only legacy registered_groups rows exist', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-'));
|
||||
tempDirs.push(tempDir);
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
agent_type TEXT
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type)
|
||||
VALUES ('dc:legacy-room', 'claude-code')
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectAssignedRooms({ dbPath })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment step legacy-room handling', () => {
|
||||
const originalCwd = process.cwd();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exits with failure when legacy-only room registrations need migration', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||
tempDirs.push(tempDir);
|
||||
process.chdir(tempDir);
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||
const db = new Database(path.join(tempDir, 'store', 'messages.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
agent_type TEXT
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type)
|
||||
VALUES ('dc:legacy-room', 'claude-code')
|
||||
`);
|
||||
db.close();
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exits with failure when canonical rooms exist but pending legacy registrations remain', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||
tempDirs.push(tempDir);
|
||||
process.chdir(tempDir);
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||
const db = new Database(path.join(tempDir, 'store', 'messages.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
agent_type TEXT
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO room_settings (chat_jid, room_mode)
|
||||
VALUES ('dc:canonical-room', 'single')
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type)
|
||||
VALUES ('dc:legacy-room', 'claude-code')
|
||||
`);
|
||||
db.close();
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exits with failure when legacy json state files need explicit migration', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-env-run-'));
|
||||
tempDirs.push(tempDir);
|
||||
process.chdir(tempDir);
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'data', 'router_state.json'),
|
||||
JSON.stringify({ last_timestamp: '1234' }),
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
await expect(runEnvironment([])).rejects.toThrow('process.exit:1');
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
import {
|
||||
canUseLinuxBubblewrapReadonlySandbox,
|
||||
@@ -17,8 +14,18 @@ import {
|
||||
isHeadless,
|
||||
isWSL,
|
||||
} from './platform.js';
|
||||
import {
|
||||
detectRoomRegistrationState,
|
||||
getLegacyMigrationGuidance,
|
||||
} from './room-registration-state.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export function detectAssignedRooms(
|
||||
options?: Parameters<typeof detectRoomRegistrationState>[0],
|
||||
): boolean {
|
||||
return detectRoomRegistrationState(options).assignedRooms > 0;
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -30,9 +37,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const hasBubblewrap = platform === 'linux' && commandExists('bwrap');
|
||||
const hasSocat = platform === 'linux' && commandExists('socat');
|
||||
const apparmorRestrictUnprivilegedUserns =
|
||||
platform === 'linux'
|
||||
? getAppArmorRestrictUnprivilegedUsernsValue()
|
||||
: null;
|
||||
platform === 'linux' ? getAppArmorRestrictUnprivilegedUsernsValue() : null;
|
||||
const hasBubblewrapReadonlySandboxCapability =
|
||||
platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox();
|
||||
|
||||
@@ -42,26 +47,15 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
const roomState = detectRoomRegistrationState({
|
||||
projectRoot,
|
||||
dbPath: path.join(projectRoot, 'store', 'messages.db'),
|
||||
});
|
||||
const legacyMigrationGuidance = getLegacyMigrationGuidance(
|
||||
roomState,
|
||||
'setup',
|
||||
);
|
||||
const status = legacyMigrationGuidance ? 'failed' : 'success';
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -69,7 +63,13 @@ export async function run(_args: string[]): Promise<void> {
|
||||
wsl,
|
||||
hasEnv,
|
||||
hasAuth,
|
||||
hasRegisteredGroups,
|
||||
assignedRooms: roomState.assignedRooms,
|
||||
legacyRegisteredGroupRows: roomState.legacyRegisteredGroupRows,
|
||||
hasLegacyRegisteredGroupsJson: roomState.hasLegacyRegisteredGroupsJson,
|
||||
legacyRoomMigrationRequired: roomState.legacyRoomMigrationRequired,
|
||||
pendingLegacyJsonStateFiles: roomState.pendingLegacyJsonStateFiles,
|
||||
legacyJsonStateMigrationRequired:
|
||||
roomState.legacyJsonStateMigrationRequired,
|
||||
hasBubblewrap,
|
||||
hasSocat,
|
||||
apparmorRestrictUnprivilegedUserns,
|
||||
@@ -84,14 +84,30 @@ export async function run(_args: string[]): Promise<void> {
|
||||
IS_HEADLESS: headless,
|
||||
HAS_ENV: hasEnv,
|
||||
HAS_AUTH: hasAuth,
|
||||
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
|
||||
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,
|
||||
HAS_BWRAP: hasBubblewrap,
|
||||
HAS_SOCAT: hasSocat,
|
||||
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
|
||||
apparmorRestrictUnprivilegedUserns ?? 'n/a',
|
||||
HAS_BWRAP_READONLY_SANDBOX_CAPABILITY:
|
||||
hasBubblewrapReadonlySandboxCapability,
|
||||
STATUS: 'success',
|
||||
...(legacyMigrationGuidance
|
||||
? {
|
||||
ERROR: legacyMigrationGuidance.error,
|
||||
NEXT_STEP: legacyMigrationGuidance.nextStep,
|
||||
}
|
||||
: {}),
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ 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'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
152
setup/migrate-json-state.test.ts
Normal file
152
setup/migrate-json-state.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
116
setup/migrate-json-state.ts
Normal file
116
setup/migrate-json-state.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
726
setup/migrate-room-registrations.test.ts
Normal file
726
setup/migrate-room-registrations.test.ts
Normal file
@@ -0,0 +1,726 @@
|
||||
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 room registrations 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('migrates pending legacy rows and legacy json into canonical room tables idempotently', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-'),
|
||||
);
|
||||
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 legacyDb = new Database(dbPath);
|
||||
legacyDb.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)
|
||||
)
|
||||
`);
|
||||
legacyDb
|
||||
.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:legacy-room',
|
||||
'Legacy Room',
|
||||
'legacy-room',
|
||||
'@Claude',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'{"provider":"anthropic"}',
|
||||
1,
|
||||
0,
|
||||
'claude-code',
|
||||
null,
|
||||
);
|
||||
legacyDb
|
||||
.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:legacy-room',
|
||||
'Legacy Room',
|
||||
'legacy-room',
|
||||
'@Codex',
|
||||
'2024-01-02T00:00:00.000Z',
|
||||
'{"provider":"openai"}',
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
null,
|
||||
);
|
||||
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');
|
||||
|
||||
await run([]);
|
||||
|
||||
const migratedDb = new Database(dbPath, { readonly: true });
|
||||
expect(
|
||||
migratedDb
|
||||
.prepare(
|
||||
`SELECT chat_jid, room_mode, mode_source, owner_agent_type
|
||||
FROM room_settings
|
||||
ORDER BY chat_jid`,
|
||||
)
|
||||
.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',
|
||||
mode_source: 'inferred',
|
||||
owner_agent_type: 'codex',
|
||||
},
|
||||
]);
|
||||
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',
|
||||
},
|
||||
{
|
||||
chat_jid: 'dc:legacy-room',
|
||||
role: 'owner',
|
||||
agent_type: 'codex',
|
||||
},
|
||||
{
|
||||
chat_jid: 'dc:legacy-room',
|
||||
role: 'reviewer',
|
||||
agent_type: 'claude-code',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
migratedDb
|
||||
.prepare(
|
||||
`SELECT jid, agent_type
|
||||
FROM registered_groups_legacy_backup
|
||||
ORDER BY jid, agent_type`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
jid: 'dc:legacy-room',
|
||||
agent_type: 'claude-code',
|
||||
},
|
||||
{
|
||||
jid: 'dc:legacy-room',
|
||||
agent_type: 'codex',
|
||||
},
|
||||
]);
|
||||
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,
|
||||
SKIPPED_ROOMS: 0,
|
||||
MIGRATED_JSON_ROOMS: 1,
|
||||
SKIPPED_JSON_ROOMS: 0,
|
||||
BACKED_UP_LEGACY_ROWS: 2,
|
||||
RENAMED_LEGACY_JSON: true,
|
||||
STATUS: 'success',
|
||||
}),
|
||||
);
|
||||
|
||||
emitStatusMock.mockClear();
|
||||
await run([]);
|
||||
expect(emitStatusMock).toHaveBeenLastCalledWith(
|
||||
'MIGRATE_ROOM_REGISTRATIONS',
|
||||
expect.objectContaining({
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills room_role_overrides for rooms that already have room_settings', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-mixed-'),
|
||||
);
|
||||
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 legacyDb = new Database(dbPath);
|
||||
legacyDb.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,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
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)
|
||||
)
|
||||
`);
|
||||
legacyDb
|
||||
.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'dc:mixed-room',
|
||||
'tribunal',
|
||||
'explicit',
|
||||
'Mixed Room',
|
||||
'mixed-room',
|
||||
'@Andy',
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
null,
|
||||
'2026-04-10T00:00:00.000Z',
|
||||
);
|
||||
legacyDb
|
||||
.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:mixed-room',
|
||||
'Mixed Room',
|
||||
'mixed-room',
|
||||
'@Andy',
|
||||
'2026-04-08T00:00:00.000Z',
|
||||
'{"provider":"openai"}',
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
null,
|
||||
);
|
||||
legacyDb
|
||||
.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:mixed-room',
|
||||
'Mixed Room',
|
||||
'mixed-room',
|
||||
'@Andy',
|
||||
'2026-04-09T00:00:00.000Z',
|
||||
'{"provider":"anthropic"}',
|
||||
1,
|
||||
0,
|
||||
'claude-code',
|
||||
null,
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
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, agent_config_json
|
||||
FROM room_role_overrides
|
||||
ORDER BY role`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
chat_jid: 'dc:mixed-room',
|
||||
role: 'owner',
|
||||
agent_type: 'codex',
|
||||
agent_config_json: '{"provider":"openai"}',
|
||||
},
|
||||
{
|
||||
chat_jid: 'dc:mixed-room',
|
||||
role: 'reviewer',
|
||||
agent_type: 'claude-code',
|
||||
agent_config_json: '{"provider":"anthropic"}',
|
||||
},
|
||||
]);
|
||||
migratedDb.close();
|
||||
|
||||
expect(emitStatusMock).toHaveBeenLastCalledWith(
|
||||
'MIGRATE_ROOM_REGISTRATIONS',
|
||||
expect.objectContaining({
|
||||
MIGRATED_ROOMS: 0,
|
||||
MIGRATED_ROLE_OVERRIDES: 2,
|
||||
BACKED_UP_LEGACY_ROWS: 2,
|
||||
STATUS: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails and leaves registered_groups.json in place when it contains invalid entries', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-invalid-json-'),
|
||||
);
|
||||
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, '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');
|
||||
|
||||
await expect(run([])).rejects.toThrow(
|
||||
/Invalid legacy registered_groups\.json folder/,
|
||||
);
|
||||
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
|
||||
).toBe(false);
|
||||
|
||||
const migratedDb = new Database(path.join(storeDir, 'messages.db'));
|
||||
expect(
|
||||
migratedDb
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN ('room_settings', 'registered_groups_legacy_backup')`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ name: 'room_settings' }]);
|
||||
expect(
|
||||
migratedDb.prepare('SELECT COUNT(*) AS count FROM room_settings').get(),
|
||||
).toEqual({ count: 0 });
|
||||
migratedDb.close();
|
||||
});
|
||||
|
||||
it('treats identical registered_groups.json entries as idempotent when room_settings already exists', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-migrate-rooms-json-idempotent-'),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const storeDir = path.join(tempRoot, 'store');
|
||||
const dataDir = path.join(tempRoot, 'data');
|
||||
fs.mkdirSync(storeDir, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
vi.stubEnv('EJCLAW_STORE_DIR', storeDir);
|
||||
vi.stubEnv('EJCLAW_DATA_DIR', dataDir);
|
||||
|
||||
const dbPath = path.join(storeDir, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL,
|
||||
mode_source TEXT NOT NULL DEFAULT 'explicit',
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
trigger_pattern TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
owner_agent_type TEXT,
|
||||
work_dir TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE room_role_overrides (
|
||||
chat_jid TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_config_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chat_jid, role)
|
||||
);
|
||||
`);
|
||||
db.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'dc:json-room',
|
||||
'single',
|
||||
'inferred',
|
||||
'JSON Room',
|
||||
'json-room',
|
||||
'@Andy',
|
||||
1,
|
||||
0,
|
||||
'claude-code',
|
||||
null,
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
);
|
||||
db.close();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'registered_groups.json'),
|
||||
JSON.stringify({
|
||||
'dc:json-room': {
|
||||
name: 'JSON Room',
|
||||
folder: 'json-room',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-02-01T00:00:00.000Z',
|
||||
agentType: 'claude-code',
|
||||
requiresTrigger: true,
|
||||
isMain: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const emitStatusMock = vi.fn();
|
||||
vi.doMock('./status.js', () => ({
|
||||
emitStatus: emitStatusMock,
|
||||
}));
|
||||
vi.doMock('../src/logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { run } = await import('./migrate-room-registrations.js');
|
||||
|
||||
await run([]);
|
||||
|
||||
const migratedDb = new Database(dbPath, { readonly: true });
|
||||
expect(
|
||||
migratedDb
|
||||
.prepare(
|
||||
`SELECT chat_jid, role, agent_type
|
||||
FROM room_role_overrides
|
||||
ORDER BY chat_jid, role`,
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
chat_jid: 'dc:json-room',
|
||||
role: 'owner',
|
||||
agent_type: 'claude-code',
|
||||
},
|
||||
]);
|
||||
migratedDb.close();
|
||||
|
||||
expect(fs.existsSync(path.join(dataDir, 'registered_groups.json'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
fs.existsSync(path.join(dataDir, 'registered_groups.json.migrated')),
|
||||
).toBe(true);
|
||||
expect(emitStatusMock).toHaveBeenLastCalledWith(
|
||||
'MIGRATE_ROOM_REGISTRATIONS',
|
||||
expect.objectContaining({
|
||||
MIGRATED_ROOMS: 0,
|
||||
MIGRATED_JSON_ROOMS: 0,
|
||||
MIGRATED_ROLE_OVERRIDES: 1,
|
||||
RENAMED_LEGACY_JSON: true,
|
||||
STATUS: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
371
setup/migrate-room-registrations.ts
Normal file
371
setup/migrate-room-registrations.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { DATA_DIR, STORE_DIR } from '../src/config.js';
|
||||
import {
|
||||
initializeDatabaseSchema,
|
||||
openPersistentDatabase,
|
||||
} from '../src/db/bootstrap.js';
|
||||
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 {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS registered_groups_legacy_backup (
|
||||
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,
|
||||
is_main INTEGER,
|
||||
agent_type TEXT,
|
||||
work_dir TEXT,
|
||||
backup_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (jid, agent_type)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function backupPendingLegacyRows(database: Database): number {
|
||||
ensureLegacyBackupTable(database);
|
||||
const pendingJids = new Set(getPendingLegacyRegisteredGroupJids(database));
|
||||
if (pendingJids.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
const rows = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT jid, name, folder, trigger_pattern, added_at, agent_config,
|
||||
requires_trigger, is_main, agent_type, work_dir
|
||||
FROM registered_groups`,
|
||||
)
|
||||
.all() as Array<Record<string, unknown>>
|
||||
).filter((row) => pendingJids.has(String(row.jid)));
|
||||
|
||||
const insert = database.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups_legacy_backup (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir,
|
||||
backup_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
insert.run(
|
||||
row.jid,
|
||||
row.name,
|
||||
row.folder,
|
||||
row.trigger_pattern,
|
||||
row.added_at,
|
||||
row.agent_config ?? null,
|
||||
row.requires_trigger ?? null,
|
||||
row.is_main ?? null,
|
||||
row.agent_type ?? null,
|
||||
row.work_dir ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
function migrateLegacyRegisteredGroupsTable(
|
||||
database: Database,
|
||||
report: MigrationReport,
|
||||
): void {
|
||||
const rows = getPendingLegacyRegisteredGroupJids(database).map((jid) => ({
|
||||
jid,
|
||||
}));
|
||||
|
||||
for (const row of rows) {
|
||||
const plan = buildLegacyRoomMigrationPlan(database, row.jid);
|
||||
if (!plan) {
|
||||
report.skippedRooms += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = database
|
||||
.prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?')
|
||||
.get(row.jid);
|
||||
if (!existing) {
|
||||
insertStoredRoomSettingsFromMigration(database, plan);
|
||||
report.migratedRooms += 1;
|
||||
}
|
||||
for (const override of plan.roleOverrides) {
|
||||
upsertRoomRoleOverride(database, row.jid, override);
|
||||
report.migratedRoleOverrides += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadValidatedLegacyRegisteredGroupsJson(
|
||||
database: Database,
|
||||
): Array<{ jid: string; group: RegisteredGroup }> {
|
||||
const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json');
|
||||
const legacyJson = readJsonFile(legacyJsonPath) as Record<
|
||||
string,
|
||||
RegisteredGroup
|
||||
> | null;
|
||||
|
||||
if (!legacyJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Object.entries(legacyJson);
|
||||
for (const [jid, group] of entries) {
|
||||
if (!isValidGroupFolder(group.folder)) {
|
||||
throw new Error(
|
||||
`Invalid legacy registered_groups.json folder for ${jid}: ${group.folder}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = getStoredRoomSettingsRowFromDatabase(database, jid);
|
||||
if (existing && !jsonEntryMatchesStoredRoom(jid, group, database)) {
|
||||
throw new Error(
|
||||
`Legacy registered_groups.json entry collides with existing room_settings row for ${jid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map(([jid, group]) => ({ jid, group }));
|
||||
}
|
||||
|
||||
function migrateLegacyRegisteredGroupsJson(
|
||||
database: Database,
|
||||
projectRoot: string,
|
||||
report: MigrationReport,
|
||||
): void {
|
||||
const legacyJsonPath = path.join(DATA_DIR, 'registered_groups.json');
|
||||
const legacyJsonEntries = loadValidatedLegacyRegisteredGroupsJson(database);
|
||||
if (legacyJsonEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insertRoom = database.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_jid) DO NOTHING`,
|
||||
);
|
||||
|
||||
for (const { jid, group } of legacyJsonEntries) {
|
||||
const createdAt = group.added_at || new Date().toISOString();
|
||||
const ownerAgentType =
|
||||
normalizeStoredAgentType(group.agentType) ?? 'claude-code';
|
||||
const existing = getStoredRoomSettingsRowFromDatabase(database, jid);
|
||||
const existingOwnerOverride = getStoredOwnerRoleOverride(database, jid);
|
||||
|
||||
if (!existing) {
|
||||
insertRoom.run(
|
||||
jid,
|
||||
'single',
|
||||
'inferred',
|
||||
group.name,
|
||||
group.folder,
|
||||
group.trigger,
|
||||
(group.requiresTrigger ?? true) ? 1 : 0,
|
||||
(group.isMain ?? false) ? 1 : 0,
|
||||
ownerAgentType,
|
||||
group.workDir ?? null,
|
||||
createdAt,
|
||||
createdAt,
|
||||
);
|
||||
report.migratedRooms += 1;
|
||||
report.migratedJsonRooms += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
existingOwnerOverride &&
|
||||
!jsonEntryMatchesStoredOwnerOverride(database, jid, group)
|
||||
) {
|
||||
throw new Error(
|
||||
`Legacy registered_groups.json owner override conflicts with existing owner override for ${jid}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingOwnerOverride) {
|
||||
upsertRoomRoleOverride(database, jid, {
|
||||
role: 'owner',
|
||||
agentType: ownerAgentType,
|
||||
agentConfig: group.agentConfig,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
report.migratedRoleOverrides += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs.renameSync(legacyJsonPath, `${legacyJsonPath}.migrated`);
|
||||
report.renamedLegacyJson = true;
|
||||
logger.info(
|
||||
{ legacyJsonPath, projectRoot },
|
||||
'Renamed registered_groups.json after explicit migration',
|
||||
);
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
fs.mkdirSync(STORE_DIR, { recursive: true });
|
||||
const projectRoot = process.cwd();
|
||||
const database = openPersistentDatabase();
|
||||
initializeDatabaseSchema(database);
|
||||
|
||||
const report: MigrationReport = {
|
||||
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();
|
||||
|
||||
emitStatus('MIGRATE_ROOM_REGISTRATIONS', {
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -1,261 +1,163 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
const {
|
||||
assignRoomMock,
|
||||
initDatabaseMock,
|
||||
emitStatusMock,
|
||||
loggerInfoMock,
|
||||
isValidGroupFolderMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assignRoomMock: vi.fn(),
|
||||
initDatabaseMock: vi.fn(),
|
||||
emitStatusMock: vi.fn(),
|
||||
loggerInfoMock: vi.fn(),
|
||||
isValidGroupFolderMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tests for the register step.
|
||||
*
|
||||
* Verifies: parameterized SQL (no injection), file templating,
|
||||
* apostrophe in names, .env updates.
|
||||
*/
|
||||
vi.mock('../src/config.js', () => ({
|
||||
GROUPS_DIR: '/tmp/ejclaw-groups',
|
||||
}));
|
||||
|
||||
function createTestDb(): Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS 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)
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
vi.mock('../src/db.js', () => ({
|
||||
assignRoom: assignRoomMock,
|
||||
initDatabase: initDatabaseMock,
|
||||
}));
|
||||
|
||||
describe('parameterized SQL registration', () => {
|
||||
let db: Database;
|
||||
vi.mock('../src/group-folder.js', () => ({
|
||||
isValidGroupFolder: isValidGroupFolderMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
vi.mock('../src/logger.js', () => ({
|
||||
logger: {
|
||||
info: loggerInfoMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./status.js', () => ({
|
||||
emitStatus: emitStatusMock,
|
||||
}));
|
||||
|
||||
import { run } from './register.js';
|
||||
|
||||
describe('register step', () => {
|
||||
afterEach(() => {
|
||||
assignRoomMock.mockReset();
|
||||
initDatabaseMock.mockReset();
|
||||
emitStatusMock.mockReset();
|
||||
loggerInfoMock.mockReset();
|
||||
isValidGroupFolderMock.mockReset();
|
||||
isValidGroupFolderMock.mockReturnValue(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers a group with parameterized query', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Test Group',
|
||||
'test-group',
|
||||
it('delegates room assignment to the canonical service without editing env or prompts', async () => {
|
||||
const mkdirSpy = vi
|
||||
.spyOn(fs, 'mkdirSync')
|
||||
.mockImplementation(() => undefined);
|
||||
const readSpy = vi.spyOn(fs, 'readFileSync');
|
||||
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
await run([
|
||||
'--jid',
|
||||
'dc:test-room',
|
||||
'--name',
|
||||
'Test Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
'--folder',
|
||||
'test-room',
|
||||
]);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
|
||||
expect(row.jid).toBe('123@g.us');
|
||||
expect(row.name).toBe('Test Group');
|
||||
expect(row.folder).toBe('test-group');
|
||||
expect(row.trigger_pattern).toBe('@Andy');
|
||||
expect(row.requires_trigger).toBe(1);
|
||||
expect(initDatabaseMock).toHaveBeenCalledTimes(1);
|
||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
|
||||
name: 'Test Room',
|
||||
folder: 'test-room',
|
||||
trigger: '@Andy',
|
||||
requiresTrigger: true,
|
||||
isMain: false,
|
||||
});
|
||||
expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
|
||||
JID: 'dc:test-room',
|
||||
NAME: 'Test Room',
|
||||
FOLDER: 'test-room',
|
||||
CHANNEL: 'discord',
|
||||
TRIGGER: '@Andy',
|
||||
REQUIRES_TRIGGER: true,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles apostrophes in group names safely', () => {
|
||||
const name = "O'Brien's Group";
|
||||
it('fails fast when deprecated assistant-name is passed', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`process.exit:${code}`);
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
name,
|
||||
'obriens-group',
|
||||
await expect(
|
||||
run([
|
||||
'--jid',
|
||||
'dc:test-room',
|
||||
'--name',
|
||||
'Test Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'--folder',
|
||||
'test-room',
|
||||
'--assistant-name',
|
||||
'Nova',
|
||||
]),
|
||||
).rejects.toThrow('process.exit:4');
|
||||
|
||||
expect(initDatabaseMock).not.toHaveBeenCalled();
|
||||
expect(assignRoomMock).not.toHaveBeenCalled();
|
||||
expect(emitStatusMock).toHaveBeenCalledWith('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'assistant_name_option_removed',
|
||||
NEXT_STEP:
|
||||
'Use a dedicated assistant identity configuration command instead',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('never writes prompt or env files during registration', async () => {
|
||||
const mkdirSpy = vi
|
||||
.spyOn(fs, 'mkdirSync')
|
||||
.mockImplementation(() => undefined);
|
||||
const readSpy = vi.spyOn(fs, 'readFileSync');
|
||||
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
|
||||
await run([
|
||||
'--jid',
|
||||
'dc:main-room',
|
||||
'--name',
|
||||
'Main Room',
|
||||
'--trigger',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
'--folder',
|
||||
'main-room',
|
||||
'--is-main',
|
||||
'--no-trigger-required',
|
||||
]);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
|
||||
.get('456@g.us') as {
|
||||
name: string;
|
||||
};
|
||||
|
||||
expect(row.name).toBe(name);
|
||||
});
|
||||
|
||||
it('prevents SQL injection in JID field', () => {
|
||||
const maliciousJid = "'; DROP TABLE registered_groups; --";
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
|
||||
// Table should still exist and have the row
|
||||
const count = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as {
|
||||
count: number;
|
||||
};
|
||||
expect(count.count).toBe(1);
|
||||
|
||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
|
||||
jid: string;
|
||||
};
|
||||
expect(row.jid).toBe(maliciousJid);
|
||||
});
|
||||
|
||||
it('handles requiresTrigger=false', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'dc:789',
|
||||
'Personal',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
||||
.get('dc:789') as { requires_trigger: number };
|
||||
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
|
||||
it('stores is_main flag', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||
).run(
|
||||
'dc:789',
|
||||
'Personal',
|
||||
'discord_main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('dc:789') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults is_main to 0', () => {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Some Group',
|
||||
'discord_some-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as { is_main: number };
|
||||
|
||||
expect(row.is_main).toBe(0);
|
||||
});
|
||||
|
||||
it('upserts on conflict', () => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
);
|
||||
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Original',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Updated',
|
||||
'main',
|
||||
'@Bot',
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all();
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const row = rows[0] as {
|
||||
name: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
expect(row.name).toBe('Updated');
|
||||
expect(row.trigger_pattern).toBe('@Bot');
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file templating', () => {
|
||||
it('replaces assistant name in CLAUDE.md content', () => {
|
||||
let content = '# Andy\n\nYou are Andy, a personal assistant.';
|
||||
|
||||
content = content.replace(/^# Andy$/m, '# Nova');
|
||||
content = content.replace(/You are Andy/g, 'You are Nova');
|
||||
|
||||
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
|
||||
});
|
||||
|
||||
it('handles names with special regex characters', () => {
|
||||
let content = '# Andy\n\nYou are Andy.';
|
||||
|
||||
const newName = 'C.L.A.U.D.E';
|
||||
content = content.replace(/^# Andy$/m, `# ${newName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${newName}`);
|
||||
|
||||
expect(content).toContain('# C.L.A.U.D.E');
|
||||
expect(content).toContain('You are C.L.A.U.D.E.');
|
||||
});
|
||||
|
||||
it('updates .env ASSISTANT_NAME line', () => {
|
||||
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
|
||||
|
||||
envContent = envContent.replace(
|
||||
/^ASSISTANT_NAME=.*$/m,
|
||||
'ASSISTANT_NAME="Nova"',
|
||||
);
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
expect(envContent).toContain('SOME_KEY=value');
|
||||
});
|
||||
|
||||
it('appends ASSISTANT_NAME to .env if not present', () => {
|
||||
let envContent = 'SOME_KEY=value\n';
|
||||
|
||||
if (!envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent += '\nASSISTANT_NAME="Nova"';
|
||||
}
|
||||
|
||||
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
|
||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
|
||||
name: 'Main Room',
|
||||
folder: 'main-room',
|
||||
trigger: '@Andy',
|
||||
requiresTrigger: false,
|
||||
isMain: true,
|
||||
});
|
||||
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Step: register — Write channel registration config, create group folders.
|
||||
* Step: register — Assign a room via the canonical application service.
|
||||
*
|
||||
* EJClaw is Discord-only, so registrations must target Discord channel IDs.
|
||||
* Uses parameterized SQL queries to prevent injection.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { GROUPS_DIR } from '../src/config.js';
|
||||
import { assignRoom, initDatabase } from '../src/db.js';
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
import { emitStatus } from './status.js';
|
||||
@@ -22,7 +20,7 @@ interface RegisterArgs {
|
||||
channel: string;
|
||||
requiresTrigger: boolean;
|
||||
isMain: boolean;
|
||||
assistantName: string;
|
||||
assistantNameProvided: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): RegisterArgs {
|
||||
@@ -34,7 +32,7 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
channel: 'discord',
|
||||
requiresTrigger: true,
|
||||
isMain: false,
|
||||
assistantName: 'Andy',
|
||||
assistantNameProvided: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -61,7 +59,8 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
result.isMain = true;
|
||||
break;
|
||||
case '--assistant-name':
|
||||
result.assistantName = args[++i] || 'Andy';
|
||||
result.assistantNameProvided = true;
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +69,6 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
|
||||
@@ -100,118 +98,33 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (parsed.assistantNameProvided) {
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'assistant_name_option_removed',
|
||||
NEXT_STEP:
|
||||
'Use a dedicated assistant identity configuration command instead',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
logger.info(parsed, 'Registering channel');
|
||||
|
||||
// Ensure data and store directories exist for fresh installs.
|
||||
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
||||
fs.mkdirSync(STORE_DIR, { recursive: true });
|
||||
initDatabase();
|
||||
assignRoom(parsed.jid, {
|
||||
name: parsed.name,
|
||||
folder: parsed.folder,
|
||||
trigger: parsed.trigger,
|
||||
requiresTrigger: parsed.requiresTrigger,
|
||||
isMain: parsed.isMain,
|
||||
});
|
||||
logger.info('Assigned room through canonical room service');
|
||||
|
||||
// Write to SQLite using parameterized queries (no SQL injection)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
const timestamp = new Date().toISOString();
|
||||
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
|
||||
|
||||
const db = new Database(dbPath);
|
||||
// Ensure schema exists
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS 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)
|
||||
)`);
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
const isMainInt = parsed.isMain ? 1 : 0;
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
parsed.jid,
|
||||
parsed.name,
|
||||
parsed.folder,
|
||||
parsed.trigger,
|
||||
timestamp,
|
||||
requiresTriggerInt,
|
||||
isMainInt,
|
||||
'claude-code',
|
||||
);
|
||||
|
||||
db.close();
|
||||
logger.info('Wrote registration to SQLite');
|
||||
|
||||
// Create group folders
|
||||
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), {
|
||||
fs.mkdirSync(path.join(GROUPS_DIR, parsed.folder, 'logs'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Update assistant name in CLAUDE.md files if different from default
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
logger.info(
|
||||
{ from: 'Andy', to: parsed.assistantName },
|
||||
'Updating assistant name',
|
||||
);
|
||||
|
||||
const mdFiles = [
|
||||
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
|
||||
path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
|
||||
];
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
if (fs.existsSync(mdFile)) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
|
||||
content = content.replace(
|
||||
/You are Andy/g,
|
||||
`You are ${parsed.assistantName}`,
|
||||
);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
|
||||
}
|
||||
}
|
||||
|
||||
// Update .env
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
let envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent = envContent.replace(
|
||||
/^ASSISTANT_NAME=.*$/m,
|
||||
`ASSISTANT_NAME="${parsed.assistantName}"`,
|
||||
);
|
||||
} else {
|
||||
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
|
||||
}
|
||||
fs.writeFileSync(envFile, envContent);
|
||||
} else {
|
||||
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
|
||||
}
|
||||
logger.info('Set ASSISTANT_NAME in .env');
|
||||
nameUpdated = true;
|
||||
}
|
||||
logger.info({ folder: parsed.folder }, 'Ensured group log directory exists');
|
||||
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
JID: parsed.jid,
|
||||
@@ -220,8 +133,6 @@ export async function run(args: string[]): Promise<void> {
|
||||
CHANNEL: parsed.channel,
|
||||
TRIGGER: parsed.trigger,
|
||||
REQUIRES_TRIGGER: parsed.requiresTrigger,
|
||||
ASSISTANT_NAME: parsed.assistantName,
|
||||
NAME_UPDATED: nameUpdated,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
149
setup/room-registration-state.ts
Normal file
149
setup/room-registration-state.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js';
|
||||
|
||||
export interface AssignedRoomsSummary {
|
||||
assignedRooms: number;
|
||||
roomsByOwnerAgent: Record<string, number>;
|
||||
legacyRegisteredGroupRows: number;
|
||||
hasLegacyRegisteredGroupsJson: boolean;
|
||||
legacyRoomMigrationRequired: boolean;
|
||||
pendingLegacyJsonStateFiles: string[];
|
||||
legacyJsonStateMigrationRequired: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationGuidance {
|
||||
error: string;
|
||||
nextStep: string;
|
||||
}
|
||||
|
||||
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'
|
||||
>,
|
||||
target: 'setup' | 'verification',
|
||||
): LegacyMigrationGuidance | undefined {
|
||||
if (
|
||||
!summary.legacyRoomMigrationRequired &&
|
||||
!summary.legacyJsonStateMigrationRequired
|
||||
) {
|
||||
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
|
||||
? '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}`;
|
||||
|
||||
return {
|
||||
error,
|
||||
nextStep: commandText,
|
||||
};
|
||||
}
|
||||
|
||||
export function detectRoomRegistrationState(
|
||||
options: {
|
||||
projectRoot?: string;
|
||||
dbPath?: string;
|
||||
} = {},
|
||||
): AssignedRoomsSummary {
|
||||
const projectRoot = options.projectRoot ?? process.cwd();
|
||||
const dbPath = options.dbPath ?? path.join(STORE_DIR, 'messages.db');
|
||||
let assignedRooms = 0;
|
||||
let legacyRegisteredGroupRows = 0;
|
||||
const roomsByOwnerAgent: Record<string, number> = {};
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM room_settings')
|
||||
.get() as { count: number };
|
||||
assignedRooms = row.count;
|
||||
} catch {
|
||||
assignedRooms = 0;
|
||||
}
|
||||
|
||||
if (assignedRooms > 0) {
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(owner_agent_type, 'unknown') AS owner_agent_type,
|
||||
COUNT(*) AS count
|
||||
FROM room_settings
|
||||
GROUP BY COALESCE(owner_agent_type, 'unknown')`,
|
||||
)
|
||||
.all() as { owner_agent_type: string; count: number }[];
|
||||
for (const current of rows) {
|
||||
roomsByOwnerAgent[current.owner_agent_type] = current.count;
|
||||
}
|
||||
} catch {
|
||||
// room_settings might not exist in older schema
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
legacyRegisteredGroupRows = countPendingLegacyRegisteredGroupRows(db);
|
||||
} catch {
|
||||
legacyRegisteredGroupRows = 0;
|
||||
}
|
||||
|
||||
db.close();
|
||||
} catch {
|
||||
// Database might not exist yet or schema might be incomplete
|
||||
}
|
||||
}
|
||||
|
||||
const hasLegacyRegisteredGroupsJson = fs.existsSync(
|
||||
path.join(getDataDir(projectRoot), 'registered_groups.json'),
|
||||
);
|
||||
const pendingLegacyJsonStateFiles =
|
||||
getPendingLegacyJsonStateFiles(projectRoot);
|
||||
const legacyRoomMigrationRequired =
|
||||
legacyRegisteredGroupRows > 0 || hasLegacyRegisteredGroupsJson;
|
||||
const legacyJsonStateMigrationRequired =
|
||||
pendingLegacyJsonStateFiles.length > 0;
|
||||
|
||||
return {
|
||||
assignedRooms,
|
||||
roomsByOwnerAgent,
|
||||
legacyRegisteredGroupRows,
|
||||
hasLegacyRegisteredGroupsJson,
|
||||
legacyRoomMigrationRequired,
|
||||
pendingLegacyJsonStateFiles,
|
||||
legacyJsonStateMigrationRequired,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
buildVerifySummary,
|
||||
detectChannelAuth,
|
||||
detectCredentials,
|
||||
loadRegisteredGroupsSummary,
|
||||
loadAssignedRoomsSummary,
|
||||
loadRoleRoutingRequirementsSummary,
|
||||
} from './verify-state.js';
|
||||
|
||||
@@ -69,9 +69,10 @@ describe('verify state helpers', () => {
|
||||
const dbPath = path.join(tempRoot, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE registered_groups (
|
||||
jid TEXT NOT NULL,
|
||||
agent_type TEXT
|
||||
CREATE TABLE room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
@@ -82,10 +83,9 @@ describe('verify state helpers', () => {
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type) VALUES
|
||||
('group-1', 'claude-code'),
|
||||
('group-1', 'codex'),
|
||||
('group-2', 'claude-code');
|
||||
INSERT INTO room_settings (chat_jid, room_mode, updated_at) VALUES
|
||||
('group-1', 'tribunal', '2024-01-01T00:00:00.000Z'),
|
||||
('group-2', 'single', '2024-01-01T00:00:00.000Z');
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO paired_tasks (id, chat_jid, status) VALUES
|
||||
@@ -94,13 +94,48 @@ describe('verify state helpers', () => {
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({
|
||||
expect(loadRoleRoutingRequirementsSummary({ dbPath })).toEqual({
|
||||
tribunalRooms: 1,
|
||||
activeArbiterTasks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads registered group counts from the sqlite store', () => {
|
||||
it('loads assigned room counts from the sqlite store', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
const dbPath = path.join(tempRoot, 'messages.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL,
|
||||
owner_agent_type TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO room_settings (chat_jid, room_mode, owner_agent_type, updated_at) VALUES
|
||||
('group-1', 'single', 'claude-code', '2024-01-01T00:00:00.000Z'),
|
||||
('group-2', 'single', 'codex', '2024-01-01T00:00:00.000Z'),
|
||||
('group-3', 'tribunal', 'codex', '2024-01-01T00:00:00.000Z');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadAssignedRoomsSummary({ dbPath })).toEqual({
|
||||
assignedRooms: 3,
|
||||
roomsByOwnerAgent: {
|
||||
'claude-code': 1,
|
||||
codex: 2,
|
||||
},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks legacy-only sqlite registrations as migration-required', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
const dbPath = path.join(tempRoot, 'messages.db');
|
||||
@@ -109,22 +144,307 @@ describe('verify state helpers', () => {
|
||||
CREATE TABLE registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
agent_type TEXT
|
||||
);
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type) VALUES
|
||||
('group-1', 'claude-code'),
|
||||
('group-2', 'codex'),
|
||||
('group-3', 'codex');
|
||||
('legacy-room', 'claude-code')
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadRegisteredGroupsSummary(dbPath)).toEqual({
|
||||
registeredGroups: 3,
|
||||
groupsByAgent: {
|
||||
'claude-code': 1,
|
||||
codex: 2,
|
||||
expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual(
|
||||
{
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 1,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: true,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('marks mixed canonical and pending legacy sqlite registrations as migration-required', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
const dbPath = path.join(tempRoot, '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,
|
||||
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)
|
||||
);
|
||||
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,
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'canonical-room',
|
||||
'single',
|
||||
'explicit',
|
||||
'Canonical Room',
|
||||
'canonical-room',
|
||||
'@Andy',
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
NULL,
|
||||
'2024-01-01T00:00:00.000Z'
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
) VALUES (
|
||||
'legacy-room',
|
||||
'Legacy Room',
|
||||
'legacy-room',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
'claude-code',
|
||||
NULL
|
||||
)
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual(
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: {
|
||||
codex: 1,
|
||||
},
|
||||
legacyRegisteredGroupRows: 1,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: true,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores legacy projection rows when matching canonical room_settings already exist', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
const dbPath = path.join(tempRoot, '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,
|
||||
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)
|
||||
);
|
||||
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,
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'same-room',
|
||||
'single',
|
||||
'explicit',
|
||||
'Same Room',
|
||||
'same-room',
|
||||
'@Andy',
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
NULL,
|
||||
'2024-01-01T00:00:00.000Z'
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
) VALUES (
|
||||
'same-room',
|
||||
'Same Room',
|
||||
'same-room',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
'codex',
|
||||
NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO room_role_overrides (
|
||||
chat_jid,
|
||||
role,
|
||||
agent_type,
|
||||
agent_config_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'same-room',
|
||||
'owner',
|
||||
'codex',
|
||||
NULL,
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'2024-01-01T00:00:00.000Z'
|
||||
)
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadAssignedRoomsSummary({ dbPath, projectRoot: tempRoot })).toEqual(
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: {
|
||||
codex: 1,
|
||||
},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('marks legacy registered_groups.json as migration-required', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, 'data', 'registered_groups.json'),
|
||||
'{"dc:legacy":{"name":"Legacy","folder":"legacy","trigger":"@Andy","added_at":"2024-01-01T00:00:00.000Z"}}',
|
||||
);
|
||||
|
||||
expect(loadAssignedRoomsSummary({ projectRoot: tempRoot })).toEqual({
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: true,
|
||||
legacyRoomMigrationRequired: true,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks pending legacy json state files as migration-required', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.mkdirSync(path.join(tempRoot, 'data'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, 'data', 'router_state.json'),
|
||||
JSON.stringify({ last_timestamp: '1234' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, 'data', 'sessions.json'),
|
||||
JSON.stringify({ 'group-a': 'session-123' }),
|
||||
);
|
||||
|
||||
expect(loadAssignedRoomsSummary({ projectRoot: tempRoot })).toEqual({
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: ['router_state.json', 'sessions.json'],
|
||||
legacyJsonStateMigrationRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,14 +470,21 @@ describe('verify state helpers', () => {
|
||||
'discord-review': 'configured',
|
||||
'discord-arbiter': 'configured',
|
||||
},
|
||||
2,
|
||||
{ codex: 1 },
|
||||
{
|
||||
assignedRooms: 2,
|
||||
roomsByOwnerAgent: { codex: 1 },
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'success',
|
||||
configuredChannels: ['discord', 'discord-review', 'discord-arbiter'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: true,
|
||||
reviewerChannelConfigured: true,
|
||||
arbiterChannelConfigured: true,
|
||||
servicesSummary: { ejclaw: 'running' },
|
||||
});
|
||||
});
|
||||
@@ -171,8 +498,15 @@ describe('verify state helpers', () => {
|
||||
[],
|
||||
'missing',
|
||||
{},
|
||||
0,
|
||||
{},
|
||||
{
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
@@ -190,14 +524,21 @@ describe('verify state helpers', () => {
|
||||
[],
|
||||
'configured',
|
||||
{ 'discord-review': 'configured' },
|
||||
1,
|
||||
{},
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord-review'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: false,
|
||||
reviewerChannelConfigured: true,
|
||||
arbiterChannelConfigured: false,
|
||||
servicesSummary: { ejclaw: 'running' },
|
||||
});
|
||||
});
|
||||
@@ -211,15 +552,22 @@ describe('verify state helpers', () => {
|
||||
[],
|
||||
'configured',
|
||||
{ discord: 'configured' },
|
||||
1,
|
||||
{},
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
{ tribunalRooms: 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord'],
|
||||
codexConfigured: false,
|
||||
reviewConfigured: false,
|
||||
reviewerChannelConfigured: false,
|
||||
arbiterChannelConfigured: false,
|
||||
tribunalRooms: 1,
|
||||
});
|
||||
});
|
||||
@@ -236,17 +584,75 @@ describe('verify state helpers', () => {
|
||||
discord: 'configured',
|
||||
'discord-review': 'configured',
|
||||
},
|
||||
1,
|
||||
{},
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
{ tribunalRooms: 1, activeArbiterTasks: 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord', 'discord-review'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: false,
|
||||
reviewerChannelConfigured: true,
|
||||
arbiterChannelConfigured: false,
|
||||
activeArbiterTasks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when legacy room migration is still required', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{ discord: 'configured' },
|
||||
{
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 2,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: true,
|
||||
pendingLegacyJsonStateFiles: [],
|
||||
legacyJsonStateMigrationRequired: false,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
legacyRoomMigrationRequired: true,
|
||||
legacyRegisteredGroupRows: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when legacy json state migration is still required', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{ discord: 'configured' },
|
||||
{
|
||||
assignedRooms: 1,
|
||||
roomsByOwnerAgent: { codex: 1 },
|
||||
legacyRegisteredGroupRows: 0,
|
||||
hasLegacyRegisteredGroupsJson: false,
|
||||
legacyRoomMigrationRequired: false,
|
||||
pendingLegacyJsonStateFiles: ['router_state.json'],
|
||||
legacyJsonStateMigrationRequired: true,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
legacyJsonStateMigrationRequired: true,
|
||||
pendingLegacyJsonStateFiles: ['router_state.json'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,34 +3,32 @@ import path from 'path';
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import {
|
||||
type AssignedRoomsSummary,
|
||||
detectRoomRegistrationState,
|
||||
} from './room-registration-state.js';
|
||||
import type { ServiceDef } from './service-defs.js';
|
||||
import type { ServiceCheck } from './verify-services.js';
|
||||
|
||||
export type CredentialsStatus = 'configured' | 'missing';
|
||||
export type VerifyStatus = 'success' | 'failed';
|
||||
|
||||
export interface RegisteredGroupsSummary {
|
||||
registeredGroups: number;
|
||||
groupsByAgent: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RoleRoutingRequirementsSummary {
|
||||
tribunalRooms: number;
|
||||
activeArbiterTasks: number;
|
||||
}
|
||||
|
||||
export interface VerifySummary extends RegisteredGroupsSummary {
|
||||
export interface VerifySummary extends AssignedRoomsSummary {
|
||||
status: VerifyStatus;
|
||||
servicesSummary: Record<string, string>;
|
||||
configuredChannels: string[];
|
||||
channelAuth: Record<string, string>;
|
||||
tribunalRooms: number;
|
||||
activeArbiterTasks: number;
|
||||
// Legacy status fields kept for backward-compatible setup output.
|
||||
codexConfigured: boolean;
|
||||
reviewConfigured: boolean;
|
||||
reviewerChannelConfigured: boolean;
|
||||
arbiterChannelConfigured: boolean;
|
||||
}
|
||||
|
||||
export function detectCredentials(projectRoot: string): CredentialsStatus {
|
||||
@@ -70,47 +68,18 @@ export function detectChannelAuth(
|
||||
return channelAuth;
|
||||
}
|
||||
|
||||
export function loadRegisteredGroupsSummary(
|
||||
dbPath = path.join(STORE_DIR, 'messages.db'),
|
||||
): RegisteredGroupsSummary {
|
||||
let registeredGroups = 0;
|
||||
const groupsByAgent: Record<string, number> = {};
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return { registeredGroups, groupsByAgent };
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
registeredGroups = row.count;
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT agent_type, COUNT(*) as count FROM registered_groups GROUP BY agent_type',
|
||||
)
|
||||
.all() as { agent_type: string; count: number }[];
|
||||
for (const current of rows) {
|
||||
groupsByAgent[current.agent_type || 'unknown'] = current.count;
|
||||
}
|
||||
} catch {
|
||||
// agent_type column might not exist in older schema
|
||||
}
|
||||
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist
|
||||
}
|
||||
|
||||
return { registeredGroups, groupsByAgent };
|
||||
export function loadAssignedRoomsSummary(
|
||||
options?: Parameters<typeof detectRoomRegistrationState>[0],
|
||||
): AssignedRoomsSummary {
|
||||
return detectRoomRegistrationState(options);
|
||||
}
|
||||
|
||||
export function loadRoleRoutingRequirementsSummary(
|
||||
dbPath = path.join(STORE_DIR, 'messages.db'),
|
||||
options: {
|
||||
dbPath?: string;
|
||||
} = {},
|
||||
): RoleRoutingRequirementsSummary {
|
||||
const dbPath = options.dbPath ?? path.join(STORE_DIR, 'messages.db');
|
||||
let tribunalRooms = 0;
|
||||
let activeArbiterTasks = 0;
|
||||
|
||||
@@ -126,39 +95,14 @@ export function loadRoleRoutingRequirementsSummary(
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count
|
||||
FROM (
|
||||
SELECT chat_jid AS jid
|
||||
FROM room_settings
|
||||
WHERE room_mode = 'tribunal'
|
||||
UNION
|
||||
SELECT jid
|
||||
FROM registered_groups
|
||||
GROUP BY jid
|
||||
HAVING COUNT(DISTINCT agent_type) > 1
|
||||
)
|
||||
FROM room_settings
|
||||
WHERE room_mode = 'tribunal'
|
||||
`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
tribunalRooms = roomModeRow.count;
|
||||
} catch {
|
||||
try {
|
||||
const fallbackRow = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count
|
||||
FROM (
|
||||
SELECT jid
|
||||
FROM registered_groups
|
||||
GROUP BY jid
|
||||
HAVING COUNT(DISTINCT agent_type) > 1
|
||||
)
|
||||
`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
tribunalRooms = fallbackRow.count;
|
||||
} catch {
|
||||
tribunalRooms = 0;
|
||||
}
|
||||
tribunalRooms = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -189,8 +133,7 @@ export function buildVerifySummary(
|
||||
serviceDefs: ServiceDef[],
|
||||
credentials: CredentialsStatus,
|
||||
channelAuth: Record<string, string>,
|
||||
registeredGroups: number,
|
||||
groupsByAgent: Record<string, number>,
|
||||
roomSummary: AssignedRoomsSummary,
|
||||
options: {
|
||||
tribunalRooms?: number;
|
||||
activeArbiterTasks?: number;
|
||||
@@ -202,20 +145,24 @@ export function buildVerifySummary(
|
||||
(service) => service.status === 'running',
|
||||
);
|
||||
const hasOwnerCapableChannel = 'discord' in channelAuth;
|
||||
const codexConfigured = 'discord-review' in channelAuth;
|
||||
const reviewConfigured = 'discord-arbiter' in channelAuth;
|
||||
const reviewerChannelConfigured = 'discord-review' in channelAuth;
|
||||
const arbiterChannelConfigured = 'discord-arbiter' in channelAuth;
|
||||
const tribunalRooms = options.tribunalRooms ?? 0;
|
||||
const activeArbiterTasks = options.activeArbiterTasks ?? 0;
|
||||
const reviewerConfigured = tribunalRooms === 0 || codexConfigured;
|
||||
const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured;
|
||||
const reviewerRoutingSatisfied =
|
||||
tribunalRooms === 0 || reviewerChannelConfigured;
|
||||
const arbiterRoutingSatisfied =
|
||||
activeArbiterTasks === 0 || arbiterChannelConfigured;
|
||||
|
||||
const status =
|
||||
allConfiguredServicesRunning &&
|
||||
credentials === 'configured' &&
|
||||
hasOwnerCapableChannel &&
|
||||
reviewerConfigured &&
|
||||
arbiterConfigured &&
|
||||
registeredGroups > 0
|
||||
reviewerRoutingSatisfied &&
|
||||
arbiterRoutingSatisfied &&
|
||||
roomSummary.assignedRooms > 0 &&
|
||||
!roomSummary.legacyRoomMigrationRequired &&
|
||||
!roomSummary.legacyJsonStateMigrationRequired
|
||||
? 'success'
|
||||
: 'failed';
|
||||
|
||||
@@ -231,9 +178,8 @@ export function buildVerifySummary(
|
||||
channelAuth,
|
||||
tribunalRooms,
|
||||
activeArbiterTasks,
|
||||
registeredGroups,
|
||||
groupsByAgent,
|
||||
codexConfigured,
|
||||
reviewConfigured,
|
||||
...roomSummary,
|
||||
reviewerChannelConfigured,
|
||||
arbiterChannelConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@
|
||||
*
|
||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from '../src/logger.js';
|
||||
import { getServiceManager } from './platform.js';
|
||||
import { getLegacyMigrationGuidance } from './room-registration-state.js';
|
||||
import { getServiceDefs } from './service-defs.js';
|
||||
import { emitStatus } from './status.js';
|
||||
import {
|
||||
buildVerifySummary,
|
||||
detectChannelAuth,
|
||||
detectCredentials,
|
||||
loadRegisteredGroupsSummary,
|
||||
loadAssignedRoomsSummary,
|
||||
loadRoleRoutingRequirementsSummary,
|
||||
} from './verify-state.js';
|
||||
import { getServiceChecks } from './verify-services.js';
|
||||
@@ -40,29 +43,33 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
const credentials = detectCredentials(projectRoot);
|
||||
const channelAuth = detectChannelAuth();
|
||||
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary();
|
||||
const dbPath = path.join(projectRoot, 'store', 'messages.db');
|
||||
const roomSummary = loadAssignedRoomsSummary({ projectRoot, dbPath });
|
||||
const { tribunalRooms, activeArbiterTasks } =
|
||||
loadRoleRoutingRequirementsSummary();
|
||||
loadRoleRoutingRequirementsSummary({ dbPath });
|
||||
const {
|
||||
status: baseStatus,
|
||||
servicesSummary,
|
||||
configuredChannels,
|
||||
tribunalRooms: detectedTribunalRooms,
|
||||
activeArbiterTasks: detectedActiveArbiterTasks,
|
||||
codexConfigured,
|
||||
reviewConfigured,
|
||||
reviewerChannelConfigured,
|
||||
arbiterChannelConfigured,
|
||||
} = buildVerifySummary(
|
||||
services,
|
||||
serviceDefs,
|
||||
credentials,
|
||||
channelAuth,
|
||||
registeredGroups,
|
||||
groupsByAgent,
|
||||
roomSummary,
|
||||
{
|
||||
tribunalRooms,
|
||||
activeArbiterTasks,
|
||||
},
|
||||
);
|
||||
const legacyMigrationGuidance = getLegacyMigrationGuidance(
|
||||
roomSummary,
|
||||
'verification',
|
||||
);
|
||||
const status = baseStatus;
|
||||
|
||||
logger.info(
|
||||
@@ -85,10 +92,24 @@ export async function run(_args: string[]): Promise<void> {
|
||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||
TRIBUNAL_ROOMS: detectedTribunalRooms,
|
||||
ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks,
|
||||
REGISTERED_GROUPS: registeredGroups,
|
||||
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
|
||||
CODEX_CONFIGURED: codexConfigured,
|
||||
REVIEW_CONFIGURED: reviewConfigured,
|
||||
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,
|
||||
REVIEWER_CHANNEL_CONFIGURED: reviewerChannelConfigured,
|
||||
ARBITER_CHANNEL_CONFIGURED: arbiterChannelConfigured,
|
||||
...(legacyMigrationGuidance
|
||||
? {
|
||||
ERROR: legacyMigrationGuidance.error,
|
||||
NEXT_STEP: legacyMigrationGuidance.nextStep,
|
||||
}
|
||||
: {}),
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user