Finalize turn-role invariants and legacy migration cutover

This commit is contained in:
ejclaw
2026-04-10 11:44:34 +09:00
parent 7753ee46f0
commit c67bd1c622
45 changed files with 15014 additions and 1381 deletions

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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'),

View 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
View 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',
});
}

View 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);
});
});

View 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',
});
}

View File

@@ -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();
});
});

View File

@@ -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',
});

View 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,
};
}

View File

@@ -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'],
});
});
});

View File

@@ -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,
};
}

View File

@@ -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',
});