Refactor local memory and role-fixed runtime routing
This commit is contained in:
123
setup/legacy-service-guard.test.ts
Normal file
123
setup/legacy-service-guard.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execSyncMock } = vi.hoisted(() => ({
|
||||
execSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
detectLegacyServiceIssues,
|
||||
formatLegacyServiceFailureMessage,
|
||||
} from './legacy-service-guard.js';
|
||||
|
||||
describe('legacy service guard', () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
execSyncMock.mockReset();
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('detects legacy systemd units in the opposite scope', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
|
||||
tempRoots.push(projectRoot);
|
||||
|
||||
execSyncMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'systemctl is-active ejclaw-codex') {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (cmd === 'systemctl list-unit-files') {
|
||||
return 'ejclaw-codex.service enabled\n';
|
||||
}
|
||||
if (cmd === 'systemctl --user is-active ejclaw-codex') {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (cmd === 'systemctl --user list-unit-files') {
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'systemctl is-active ejclaw-review') {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (cmd === 'systemctl --user is-active ejclaw-review') {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (cmd === 'systemctl list-unit-files' || cmd === 'systemctl --user list-unit-files') {
|
||||
return '';
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
|
||||
{
|
||||
name: 'ejclaw-codex',
|
||||
status: 'stopped',
|
||||
sources: ['systemd-system'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects legacy nohup artifacts even on systemd hosts', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
|
||||
tempRoots.push(projectRoot);
|
||||
fs.writeFileSync(path.join(projectRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
|
||||
|
||||
execSyncMock.mockImplementation((cmd: string) => {
|
||||
if (
|
||||
cmd === 'systemctl is-active ejclaw-codex' ||
|
||||
cmd === 'systemctl --user is-active ejclaw-codex' ||
|
||||
cmd === 'systemctl is-active ejclaw-review' ||
|
||||
cmd === 'systemctl --user is-active ejclaw-review'
|
||||
) {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (
|
||||
cmd === 'systemctl list-unit-files' ||
|
||||
cmd === 'systemctl --user list-unit-files'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
|
||||
{
|
||||
name: 'ejclaw-codex',
|
||||
status: 'stopped',
|
||||
sources: ['nohup'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats cleanup instructions for the detected scopes only', () => {
|
||||
const message = formatLegacyServiceFailureMessage({
|
||||
projectRoot: '/srv/ejclaw',
|
||||
serviceManager: 'systemd',
|
||||
homeDir: '/home/user',
|
||||
services: [
|
||||
{
|
||||
name: 'ejclaw-codex',
|
||||
status: 'stopped',
|
||||
sources: ['systemd-system', 'nohup'],
|
||||
},
|
||||
{
|
||||
name: 'ejclaw-review',
|
||||
status: 'running',
|
||||
sources: ['systemd-user'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(message).toContain('systemctl disable --now ejclaw-codex');
|
||||
expect(message).toContain('systemctl --user disable --now ejclaw-review');
|
||||
expect(message).toContain('pkill -F "/srv/ejclaw/ejclaw-codex.pid"');
|
||||
expect(message).not.toContain('systemctl --user disable --now ejclaw-codex');
|
||||
});
|
||||
});
|
||||
209
setup/legacy-service-guard.ts
Normal file
209
setup/legacy-service-guard.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { ServiceManager } from './platform.js';
|
||||
import { getLegacyServiceDefs } from './service-defs.js';
|
||||
import {
|
||||
checkLaunchdServiceArtifact,
|
||||
checkNohupServiceArtifact,
|
||||
checkSystemdServiceInScope,
|
||||
type ServiceCheck,
|
||||
type SystemdScope,
|
||||
} from './verify-services.js';
|
||||
|
||||
type LegacySource = 'launchd' | 'systemd-system' | 'systemd-user' | 'nohup';
|
||||
|
||||
export interface LegacyServiceIssue extends ServiceCheck {
|
||||
sources: LegacySource[];
|
||||
}
|
||||
|
||||
function summarizeLegacyStatus(statuses: ServiceCheck['status'][]): ServiceCheck['status'] {
|
||||
if (statuses.includes('running')) {
|
||||
return 'running';
|
||||
}
|
||||
if (statuses.includes('stopped')) {
|
||||
return 'stopped';
|
||||
}
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
export function detectLegacyServiceIssues(
|
||||
projectRoot: string,
|
||||
serviceManager: ServiceManager,
|
||||
homeDir = os.homedir(),
|
||||
): LegacyServiceIssue[] {
|
||||
return getLegacyServiceDefs(projectRoot)
|
||||
.map((def) => {
|
||||
if (serviceManager === 'launchd') {
|
||||
const status = checkLaunchdServiceArtifact(
|
||||
def.launchdLabel,
|
||||
path.join(homeDir, 'Library', 'LaunchAgents', `${def.launchdLabel}.plist`),
|
||||
);
|
||||
if (status === 'not_found') return null;
|
||||
return {
|
||||
name: def.name,
|
||||
status,
|
||||
sources: ['launchd'] as LegacySource[],
|
||||
};
|
||||
}
|
||||
|
||||
if (serviceManager === 'systemd') {
|
||||
const scopedChecks: Array<{ source: LegacySource; status: ServiceCheck['status'] }> = [
|
||||
{
|
||||
source: 'systemd-system',
|
||||
status: checkSystemdServiceInScope(def.name, 'system'),
|
||||
},
|
||||
{
|
||||
source: 'systemd-user',
|
||||
status: checkSystemdServiceInScope(def.name, 'user'),
|
||||
},
|
||||
{
|
||||
source: 'nohup',
|
||||
status: checkNohupServiceArtifact(projectRoot, def.name),
|
||||
},
|
||||
];
|
||||
const detected = scopedChecks.filter(
|
||||
(current) => current.status !== 'not_found',
|
||||
);
|
||||
if (detected.length === 0) return null;
|
||||
return {
|
||||
name: def.name,
|
||||
status: summarizeLegacyStatus(detected.map((current) => current.status)),
|
||||
sources: detected.map((current) => current.source),
|
||||
};
|
||||
}
|
||||
|
||||
const status = checkNohupServiceArtifact(projectRoot, def.name);
|
||||
if (status === 'not_found') return null;
|
||||
return {
|
||||
name: def.name,
|
||||
status,
|
||||
sources: ['nohup'] as LegacySource[],
|
||||
};
|
||||
})
|
||||
.filter((service): service is LegacyServiceIssue => Boolean(service));
|
||||
}
|
||||
|
||||
function formatSystemdCleanupForScope(
|
||||
serviceNames: string[],
|
||||
homeDir: string,
|
||||
scope: SystemdScope,
|
||||
): string[] {
|
||||
if (serviceNames.length === 0) return [];
|
||||
const systemctlPrefix = scope === 'system' ? 'systemctl' : 'systemctl --user';
|
||||
const unitDir =
|
||||
scope === 'system'
|
||||
? '/etc/systemd/system'
|
||||
: path.join(homeDir, '.config', 'systemd', 'user');
|
||||
const unitPaths = serviceNames
|
||||
.map((serviceName) =>
|
||||
JSON.stringify(path.join(unitDir, `${serviceName}.service`)),
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return [
|
||||
`${systemctlPrefix} disable --now ${serviceNames.join(' ')}`,
|
||||
`rm -f ${unitPaths}`,
|
||||
`${systemctlPrefix} daemon-reload`,
|
||||
];
|
||||
}
|
||||
|
||||
function formatLaunchdCleanup(serviceNames: string[], homeDir: string): string {
|
||||
if (serviceNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const plistPaths = serviceNames.map((serviceName) => {
|
||||
const label = serviceName === 'ejclaw-codex'
|
||||
? 'com.ejclaw-codex'
|
||||
: 'com.ejclaw-review';
|
||||
return path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
|
||||
});
|
||||
|
||||
return [
|
||||
...plistPaths.map(
|
||||
(plistPath) =>
|
||||
`launchctl unload ${JSON.stringify(plistPath)} 2>/dev/null || true`,
|
||||
),
|
||||
`rm -f ${plistPaths.map((plistPath) => JSON.stringify(plistPath)).join(' ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatNohupCleanup(
|
||||
projectRoot: string,
|
||||
serviceNames: string[],
|
||||
): string {
|
||||
if (serviceNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const pidPaths = serviceNames
|
||||
.map((serviceName) => path.join(projectRoot, `${serviceName}.pid`))
|
||||
.map((currentPath) => JSON.stringify(currentPath));
|
||||
const wrapperPaths = serviceNames
|
||||
.map((serviceName) => path.join(projectRoot, `start-${serviceName}.sh`))
|
||||
.map((currentPath) => JSON.stringify(currentPath));
|
||||
|
||||
return [
|
||||
...pidPaths.map((pidPath) => `pkill -F ${pidPath} 2>/dev/null || true`),
|
||||
`rm -f ${[...pidPaths, ...wrapperPaths].join(' ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function formatLegacyServiceFailureMessage(args: {
|
||||
projectRoot: string;
|
||||
serviceManager: ServiceManager;
|
||||
services: LegacyServiceIssue[];
|
||||
homeDir?: string;
|
||||
}): string {
|
||||
const homeDir = args.homeDir ?? os.homedir();
|
||||
const details = args.services
|
||||
.map(
|
||||
(service) =>
|
||||
`${service.name}=${service.status} [${service.sources.join(',')}]`,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
let cleanupLines: string[] = [];
|
||||
if (args.serviceManager === 'launchd') {
|
||||
cleanupLines = formatLaunchdCleanup(
|
||||
args.services
|
||||
.filter((service) => service.sources.includes('launchd'))
|
||||
.map((service) => service.name),
|
||||
homeDir,
|
||||
).split('\n');
|
||||
} else if (args.serviceManager === 'systemd') {
|
||||
cleanupLines = [
|
||||
...formatSystemdCleanupForScope(
|
||||
args.services
|
||||
.filter((service) => service.sources.includes('systemd-system'))
|
||||
.map((service) => service.name),
|
||||
homeDir,
|
||||
'system',
|
||||
),
|
||||
...formatSystemdCleanupForScope(
|
||||
args.services
|
||||
.filter((service) => service.sources.includes('systemd-user'))
|
||||
.map((service) => service.name),
|
||||
homeDir,
|
||||
'user',
|
||||
),
|
||||
...formatNohupCleanup(
|
||||
args.projectRoot,
|
||||
args.services
|
||||
.filter((service) => service.sources.includes('nohup'))
|
||||
.map((service) => service.name),
|
||||
).split('\n'),
|
||||
].filter(Boolean);
|
||||
} else {
|
||||
cleanupLines = formatNohupCleanup(
|
||||
args.projectRoot,
|
||||
args.services.map((service) => service.name),
|
||||
).split('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`Legacy EJClaw multi-service install detected: ${details}`,
|
||||
'This setup is reinstall-only. Remove the legacy services before continuing.',
|
||||
'Suggested cleanup:',
|
||||
...cleanupLines,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -21,8 +21,6 @@ describe('restartStackServices', () => {
|
||||
it('dispatches stack restart through the oneshot unit by default', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
|
||||
|
||||
const execFileSyncImpl = vi.fn();
|
||||
|
||||
@@ -33,7 +31,7 @@ describe('restartStackServices', () => {
|
||||
serviceId: null,
|
||||
});
|
||||
|
||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
||||
expect(services).toEqual(['ejclaw']);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'systemctl',
|
||||
@@ -43,11 +41,9 @@ describe('restartStackServices', () => {
|
||||
expect(execFileSyncImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restarts and verifies the configured three-service stack in direct mode', () => {
|
||||
it('restarts and verifies the unified service in direct mode', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
|
||||
|
||||
const execFileSyncImpl = vi.fn();
|
||||
|
||||
@@ -59,11 +55,11 @@ describe('restartStackServices', () => {
|
||||
serviceId: null,
|
||||
});
|
||||
|
||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
||||
expect(services).toEqual(['ejclaw']);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'systemctl',
|
||||
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
|
||||
['--user', 'restart', 'ejclaw'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
@@ -72,18 +68,7 @@ describe('restartStackServices', () => {
|
||||
['--user', 'is-active', '--quiet', 'ejclaw'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'systemctl',
|
||||
['--user', 'is-active', '--quiet', 'ejclaw-codex'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'systemctl',
|
||||
['--user', 'is-active', '--quiet', 'ejclaw-review'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(execFileSyncImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects non-systemd environments', () => {
|
||||
@@ -97,11 +82,34 @@ describe('restartStackServices', () => {
|
||||
).toThrow('restart:stack only supports Linux systemd services in this repo');
|
||||
});
|
||||
|
||||
it('fails fast when legacy services are still present', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
const execFileSyncImpl = vi.fn();
|
||||
|
||||
expect(() =>
|
||||
restartStackServices(tempRoot, {
|
||||
direct: true,
|
||||
execFileSyncImpl,
|
||||
runningAsRoot: false,
|
||||
serviceManager: 'systemd',
|
||||
serviceId: null,
|
||||
legacyServiceIssues: [
|
||||
{
|
||||
name: 'ejclaw-codex',
|
||||
status: 'stopped',
|
||||
sources: ['systemd-system'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow('Legacy EJClaw multi-service install detected');
|
||||
expect(execFileSyncImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
|
||||
|
||||
const execFileSyncImpl = vi
|
||||
.fn()
|
||||
@@ -120,7 +128,7 @@ describe('restartStackServices', () => {
|
||||
serviceId: null,
|
||||
});
|
||||
|
||||
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
|
||||
expect(services).toEqual(['ejclaw']);
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'systemctl',
|
||||
@@ -130,7 +138,7 @@ describe('restartStackServices', () => {
|
||||
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'systemctl',
|
||||
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
|
||||
['--user', 'restart', 'ejclaw'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
});
|
||||
@@ -138,7 +146,6 @@ describe('restartStackServices', () => {
|
||||
it('rejects direct fallback when the unit is missing for a managed service caller', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
|
||||
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
||||
const error = new Error('unit missing');
|
||||
@@ -161,7 +168,6 @@ describe('restartStackServices', () => {
|
||||
it('does not hide a general start failure behind direct fallback', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
|
||||
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
||||
const error = new Error('job failed');
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import {
|
||||
detectLegacyServiceIssues,
|
||||
formatLegacyServiceFailureMessage,
|
||||
type LegacyServiceIssue,
|
||||
} from './legacy-service-guard.js';
|
||||
import { getServiceManager, isRoot } from './platform.js';
|
||||
import { getConfiguredServiceNames } from './service-defs.js';
|
||||
|
||||
@@ -17,6 +23,8 @@ interface RestartStackDeps {
|
||||
serviceManager?: ServiceManager;
|
||||
direct?: boolean;
|
||||
serviceId?: string | null;
|
||||
legacyServiceIssues?: LegacyServiceIssue[];
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
function restartStackServicesDirect(
|
||||
@@ -62,6 +70,24 @@ export function restartStackServices(
|
||||
);
|
||||
}
|
||||
|
||||
const legacyServiceIssues =
|
||||
deps.legacyServiceIssues ??
|
||||
detectLegacyServiceIssues(
|
||||
projectRoot,
|
||||
serviceManager,
|
||||
deps.homeDir ?? os.homedir(),
|
||||
);
|
||||
if (legacyServiceIssues.length > 0) {
|
||||
throw new Error(
|
||||
formatLegacyServiceFailureMessage({
|
||||
projectRoot,
|
||||
serviceManager,
|
||||
services: legacyServiceIssues,
|
||||
homeDir: deps.homeDir,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const services = getConfiguredServiceNames(projectRoot);
|
||||
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
||||
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type ServiceKind = 'primary' | 'codex' | 'review';
|
||||
export type ServiceKind = 'primary' | 'legacy';
|
||||
|
||||
export interface ServiceDef {
|
||||
/** Stable topology kind used by setup/verify logic */
|
||||
@@ -26,11 +23,9 @@ interface ServiceTemplate {
|
||||
launchdLabel: string;
|
||||
description: string;
|
||||
logName: string;
|
||||
envFileName?: string;
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
const SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||
const CURRENT_SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||
{
|
||||
kind: 'primary',
|
||||
name: 'ejclaw',
|
||||
@@ -38,37 +33,27 @@ const SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||
description: 'EJClaw Personal Assistant (Claude Code)',
|
||||
logName: 'ejclaw',
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [
|
||||
{
|
||||
kind: 'codex',
|
||||
kind: 'legacy',
|
||||
name: 'ejclaw-codex',
|
||||
launchdLabel: 'com.ejclaw-codex',
|
||||
description: 'EJClaw Codex Assistant',
|
||||
description: 'Legacy EJClaw Codex Assistant',
|
||||
logName: 'ejclaw-codex',
|
||||
envFileName: '.env.codex',
|
||||
assistantName: 'codex',
|
||||
},
|
||||
{
|
||||
kind: 'review',
|
||||
kind: 'legacy',
|
||||
name: 'ejclaw-review',
|
||||
launchdLabel: 'com.ejclaw-review',
|
||||
description: 'EJClaw Codex Review Assistant',
|
||||
description: 'Legacy EJClaw Codex Review Assistant',
|
||||
logName: 'ejclaw-review',
|
||||
envFileName: '.env.codex-review',
|
||||
assistantName: 'codex',
|
||||
},
|
||||
];
|
||||
|
||||
function materializeServiceDef(
|
||||
projectRoot: string,
|
||||
template: ServiceTemplate,
|
||||
): ServiceDef | null {
|
||||
const environmentFile = template.envFileName
|
||||
? path.join(projectRoot, template.envFileName)
|
||||
: undefined;
|
||||
|
||||
if (environmentFile && !fs.existsSync(environmentFile)) {
|
||||
return null;
|
||||
}
|
||||
function materializeServiceDef(template: ServiceTemplate): ServiceDef {
|
||||
const environmentFile = undefined;
|
||||
|
||||
return {
|
||||
kind: template.kind,
|
||||
@@ -77,21 +62,28 @@ function materializeServiceDef(
|
||||
description: template.description,
|
||||
logName: template.logName,
|
||||
environmentFile,
|
||||
extraEnv: template.assistantName
|
||||
? {
|
||||
ASSISTANT_NAME: template.assistantName,
|
||||
}
|
||||
: undefined,
|
||||
extraEnv: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getServiceDefs(projectRoot: string): ServiceDef[] {
|
||||
return SERVICE_TEMPLATES.flatMap((template) => {
|
||||
const def = materializeServiceDef(projectRoot, template);
|
||||
return def ? [def] : [];
|
||||
});
|
||||
void projectRoot;
|
||||
return CURRENT_SERVICE_TEMPLATES.map((template) =>
|
||||
materializeServiceDef(template),
|
||||
);
|
||||
}
|
||||
|
||||
export function getLegacyServiceDefs(projectRoot: string): ServiceDef[] {
|
||||
void projectRoot;
|
||||
return LEGACY_SERVICE_TEMPLATES.map((template) =>
|
||||
materializeServiceDef(template),
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredServiceNames(projectRoot: string): string[] {
|
||||
return getServiceDefs(projectRoot).map((def) => def.name);
|
||||
}
|
||||
|
||||
export function getLegacyServiceNames(projectRoot: string): string[] {
|
||||
return getLegacyServiceDefs(projectRoot).map((def) => def.name);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
buildStackRestartSystemdUnit,
|
||||
buildSystemdUnit,
|
||||
} from './service-renderers.js';
|
||||
import { getServiceDefs, type ServiceDef } from './service-defs.js';
|
||||
import {
|
||||
getLegacyServiceDefs,
|
||||
getServiceDefs,
|
||||
type ServiceDef,
|
||||
} from './service-defs.js';
|
||||
|
||||
/**
|
||||
* Tests for service configuration generation.
|
||||
@@ -127,19 +131,18 @@ describe('systemd unit generation', () => {
|
||||
const unit = buildSystemdUnit(
|
||||
{
|
||||
...baseServiceDef,
|
||||
kind: 'codex',
|
||||
environmentFile: '/srv/ejclaw/.env.codex',
|
||||
kind: 'primary',
|
||||
environmentFile: '/srv/ejclaw/.env.extra',
|
||||
extraEnv: { ASSISTANT_NAME: 'codex' },
|
||||
logName: 'ejclaw-codex',
|
||||
name: 'ejclaw-codex',
|
||||
logName: 'ejclaw',
|
||||
name: 'ejclaw',
|
||||
},
|
||||
'/srv/ejclaw',
|
||||
'/usr/bin/bun',
|
||||
'/home/user',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.codex');
|
||||
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.extra');
|
||||
expect(unit).toContain('Environment=ASSISTANT_NAME=codex');
|
||||
});
|
||||
});
|
||||
@@ -173,24 +176,27 @@ describe('service definitions', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the review service when .env.codex-review exists', () => {
|
||||
it('returns only the unified service definition', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
|
||||
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
|
||||
|
||||
const defs = getServiceDefs(tempRoot);
|
||||
|
||||
expect(defs.map((def) => def.name)).toEqual(['ejclaw']);
|
||||
expect(defs.map((def) => def.kind)).toEqual(['primary']);
|
||||
});
|
||||
|
||||
it('keeps legacy service identities available for migration guards', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
const defs = getLegacyServiceDefs(tempRoot);
|
||||
|
||||
expect(defs.map((def) => def.name)).toEqual([
|
||||
'ejclaw',
|
||||
'ejclaw-codex',
|
||||
'ejclaw-review',
|
||||
]);
|
||||
expect(defs.map((def) => def.kind)).toEqual([
|
||||
'primary',
|
||||
'codex',
|
||||
'review',
|
||||
]);
|
||||
expect(defs.map((def) => def.kind)).toEqual(['legacy', 'legacy']);
|
||||
});
|
||||
|
||||
it('generates a oneshot stack restart unit', () => {
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
* Step: service — Generate and load service manager config.
|
||||
* Replaces 08-setup-service.sh
|
||||
*
|
||||
* Supports the EJClaw service stack:
|
||||
* - ejclaw (Claude Code) — always installed
|
||||
* - ejclaw-codex (Codex) — installed when .env.codex exists
|
||||
* - ejclaw-review (Codex Review) — installed when .env.codex-review exists
|
||||
* Supports the unified EJClaw service:
|
||||
* - ejclaw — always installed
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
@@ -13,7 +11,11 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from '../src/logger.js';
|
||||
import { getPlatform, getNodePath } from './platform.js';
|
||||
import { getPlatform, getNodePath, getServiceManager } from './platform.js';
|
||||
import {
|
||||
detectLegacyServiceIssues,
|
||||
formatLegacyServiceFailureMessage,
|
||||
} from './legacy-service-guard.js';
|
||||
import { getServiceDefs } from './service-defs.js';
|
||||
import { setupLaunchd, setupLinux } from './service-installers.js';
|
||||
import { emitStatus } from './status.js';
|
||||
@@ -27,9 +29,41 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const platform = getPlatform();
|
||||
const nodePath = getNodePath();
|
||||
const homeDir = os.homedir();
|
||||
const serviceManager = getServiceManager();
|
||||
|
||||
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
|
||||
|
||||
const legacyServiceIssues = detectLegacyServiceIssues(
|
||||
projectRoot,
|
||||
serviceManager,
|
||||
homeDir,
|
||||
);
|
||||
if (legacyServiceIssues.length > 0) {
|
||||
const errorMessage = formatLegacyServiceFailureMessage({
|
||||
projectRoot,
|
||||
serviceManager,
|
||||
homeDir,
|
||||
services: legacyServiceIssues,
|
||||
});
|
||||
logger.error(
|
||||
{ legacyServiceIssues, serviceManager },
|
||||
'Legacy multi-service install detected during setup',
|
||||
);
|
||||
emitStatus('SETUP_SERVICE', {
|
||||
SERVICE_TYPE: serviceManager,
|
||||
NODE_PATH: nodePath,
|
||||
PROJECT_PATH: projectRoot,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'legacy_services_detected',
|
||||
LEGACY_SERVICES: legacyServiceIssues
|
||||
.map((service) => `${service.name}:${service.status}`)
|
||||
.join(','),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
console.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build first
|
||||
logger.info('Building TypeScript');
|
||||
try {
|
||||
@@ -54,14 +88,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||
|
||||
const serviceDefs = getServiceDefs(projectRoot);
|
||||
for (const def of serviceDefs) {
|
||||
if (def.kind === 'primary' || !def.environmentFile) {
|
||||
continue;
|
||||
}
|
||||
logger.info(
|
||||
`Detected ${path.basename(def.environmentFile)} — will also install ${def.name} service`,
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
for (const def of serviceDefs) {
|
||||
|
||||
@@ -18,8 +18,11 @@ vi.mock('./platform.js', () => ({
|
||||
|
||||
import {
|
||||
checkLaunchdService,
|
||||
checkLaunchdServiceArtifact,
|
||||
checkNohupService,
|
||||
checkNohupServiceArtifact,
|
||||
checkSystemdService,
|
||||
checkSystemdServiceInScope,
|
||||
getServiceChecks,
|
||||
} from './verify-services.js';
|
||||
import type { ServiceDef } from './service-defs.js';
|
||||
@@ -54,6 +57,43 @@ describe('verify service checks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('checks systemd services in both explicit scopes', () => {
|
||||
execSyncMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'systemctl is-active ejclaw-codex') {
|
||||
return undefined;
|
||||
}
|
||||
if (cmd === 'systemctl --user is-active ejclaw-codex') {
|
||||
throw new Error('inactive');
|
||||
}
|
||||
if (cmd === 'systemctl --user list-unit-files') {
|
||||
return '';
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running');
|
||||
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found');
|
||||
});
|
||||
|
||||
it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-launchd-'));
|
||||
const plistPath = path.join(
|
||||
tempHome,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
'com.ejclaw-codex.plist',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||
fs.writeFileSync(plistPath, '<plist />');
|
||||
execSyncMock.mockReturnValue('');
|
||||
|
||||
expect(
|
||||
checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath),
|
||||
).toBe('stopped');
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats known but inactive systemd services as stopped', () => {
|
||||
execSyncMock
|
||||
.mockImplementationOnce(() => {
|
||||
@@ -79,6 +119,15 @@ describe('verify service checks', () => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
|
||||
|
||||
expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped');
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('builds per-service status checks from service definitions', () => {
|
||||
const defs: ServiceDef[] = [
|
||||
{
|
||||
@@ -89,18 +138,18 @@ describe('verify service checks', () => {
|
||||
logName: 'ejclaw',
|
||||
},
|
||||
{
|
||||
kind: 'codex',
|
||||
name: 'ejclaw-codex',
|
||||
description: 'Codex',
|
||||
launchdLabel: 'com.ejclaw.codex',
|
||||
logName: 'ejclaw-codex',
|
||||
kind: 'primary',
|
||||
name: 'ejclaw-secondary',
|
||||
description: 'Secondary',
|
||||
launchdLabel: 'com.ejclaw.secondary',
|
||||
logName: 'ejclaw-secondary',
|
||||
},
|
||||
];
|
||||
execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n');
|
||||
|
||||
expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([
|
||||
{ name: 'ejclaw', status: 'running' },
|
||||
{ name: 'ejclaw-codex', status: 'not_found' },
|
||||
{ name: 'ejclaw-secondary', status: 'not_found' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,13 @@ export interface ServiceCheck {
|
||||
status: ServiceStatus;
|
||||
}
|
||||
|
||||
export interface ServiceCheckOptions {
|
||||
detectArtifacts?: boolean;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export type SystemdScope = 'system' | 'user';
|
||||
|
||||
export function checkLaunchdService(label: string): ServiceStatus {
|
||||
try {
|
||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||
@@ -32,8 +39,26 @@ export function checkLaunchdService(label: string): ServiceStatus {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
export function checkSystemdService(name: string): ServiceStatus {
|
||||
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
||||
function getLaunchdPlistPath(homeDir: string, launchdLabel: string): string {
|
||||
return path.join(homeDir, 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
|
||||
}
|
||||
|
||||
export function checkLaunchdServiceArtifact(
|
||||
label: string,
|
||||
plistPath: string,
|
||||
): ServiceStatus {
|
||||
const status = checkLaunchdService(label);
|
||||
if (status !== 'not_found') {
|
||||
return status;
|
||||
}
|
||||
return fs.existsSync(plistPath) ? 'stopped' : 'not_found';
|
||||
}
|
||||
|
||||
export function checkSystemdServiceInScope(
|
||||
name: string,
|
||||
scope: SystemdScope,
|
||||
): ServiceStatus {
|
||||
const prefix = scope === 'system' ? 'systemctl' : 'systemctl --user';
|
||||
try {
|
||||
execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' });
|
||||
return 'running';
|
||||
@@ -52,6 +77,10 @@ export function checkSystemdService(name: string): ServiceStatus {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
export function checkSystemdService(name: string): ServiceStatus {
|
||||
return checkSystemdServiceInScope(name, isRoot() ? 'system' : 'user');
|
||||
}
|
||||
|
||||
export function checkNohupService(
|
||||
projectRoot: string,
|
||||
serviceName: string,
|
||||
@@ -72,18 +101,40 @@ export function checkNohupService(
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
export function checkNohupServiceArtifact(
|
||||
projectRoot: string,
|
||||
serviceName: string,
|
||||
): ServiceStatus {
|
||||
const status = checkNohupService(projectRoot, serviceName);
|
||||
if (status !== 'not_found') {
|
||||
return status;
|
||||
}
|
||||
const wrapperPath = path.join(projectRoot, `start-${serviceName}.sh`);
|
||||
return fs.existsSync(wrapperPath) ? 'stopped' : 'not_found';
|
||||
}
|
||||
|
||||
export function checkService(
|
||||
projectRoot: string,
|
||||
serviceManager: 'launchd' | 'systemd' | 'none',
|
||||
serviceName: string,
|
||||
launchdLabel: string,
|
||||
options: ServiceCheckOptions = {},
|
||||
): ServiceStatus {
|
||||
if (serviceManager === 'launchd') {
|
||||
if (options.detectArtifacts && options.homeDir) {
|
||||
return checkLaunchdServiceArtifact(
|
||||
launchdLabel,
|
||||
getLaunchdPlistPath(options.homeDir, launchdLabel),
|
||||
);
|
||||
}
|
||||
return checkLaunchdService(launchdLabel);
|
||||
}
|
||||
if (serviceManager === 'systemd') {
|
||||
return checkSystemdService(serviceName);
|
||||
}
|
||||
if (options.detectArtifacts) {
|
||||
return checkNohupServiceArtifact(projectRoot, serviceName);
|
||||
}
|
||||
return checkNohupService(projectRoot, serviceName);
|
||||
}
|
||||
|
||||
@@ -91,6 +142,7 @@ export function getServiceChecks(
|
||||
serviceDefs: ServiceDef[],
|
||||
projectRoot: string,
|
||||
serviceManager: 'launchd' | 'systemd' | 'none',
|
||||
options: ServiceCheckOptions = {},
|
||||
): ServiceCheck[] {
|
||||
return serviceDefs.map((def) => ({
|
||||
name: def.name,
|
||||
@@ -99,6 +151,7 @@ export function getServiceChecks(
|
||||
serviceManager,
|
||||
def.name,
|
||||
def.launchdLabel,
|
||||
options,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
buildVerifySummary,
|
||||
detectChannelAuth,
|
||||
detectCredentials,
|
||||
detectLegacyDiscordTokenKeys,
|
||||
loadRegisteredGroupsSummary,
|
||||
loadRoleRoutingRequirementsSummary,
|
||||
} from './verify-state.js';
|
||||
|
||||
describe('verify state helpers', () => {
|
||||
@@ -34,14 +36,88 @@ describe('verify state helpers', () => {
|
||||
expect(detectCredentials(tempRoot)).toBe('configured');
|
||||
});
|
||||
|
||||
it('detects channel auth from either env source', () => {
|
||||
it('detects canonical role-based channel auth names from process env', () => {
|
||||
expect(
|
||||
detectChannelAuth(
|
||||
{ DISCORD_BOT_TOKEN: '' },
|
||||
{ DISCORD_BOT_TOKEN: 'discord-token' },
|
||||
{},
|
||||
{
|
||||
DISCORD_OWNER_BOT_TOKEN: 'owner-token',
|
||||
DISCORD_REVIEWER_BOT_TOKEN: 'reviewer-token',
|
||||
DISCORD_ARBITER_BOT_TOKEN: 'arbiter-token',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
discord: 'configured',
|
||||
'discord-review': 'configured',
|
||||
'discord-arbiter': 'configured',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat legacy service-based channel auth names as configured channels', () => {
|
||||
expect(
|
||||
detectChannelAuth(
|
||||
{},
|
||||
{
|
||||
DISCORD_CLAUDE_BOT_TOKEN: 'legacy-owner-token',
|
||||
DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token',
|
||||
DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token',
|
||||
},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('detects legacy service-based discord token names from env file and process env', () => {
|
||||
expect(
|
||||
detectLegacyDiscordTokenKeys(
|
||||
{
|
||||
DISCORD_BOT_TOKEN: 'legacy-owner-token',
|
||||
},
|
||||
{
|
||||
DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token',
|
||||
DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token',
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
'DISCORD_BOT_TOKEN',
|
||||
'DISCORD_CODEX_MAIN_BOT_TOKEN',
|
||||
'DISCORD_CODEX_REVIEW_BOT_TOKEN',
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads paired-room routing requirements 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 registered_groups (
|
||||
jid TEXT NOT NULL,
|
||||
agent_type TEXT
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE paired_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO registered_groups (jid, agent_type) VALUES
|
||||
('group-1', 'claude-code'),
|
||||
('group-1', 'codex'),
|
||||
('group-2', 'claude-code');
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO paired_tasks (id, chat_jid, status) VALUES
|
||||
('task-1', 'group-1', 'arbiter_requested'),
|
||||
('task-2', 'group-2', 'completed');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({
|
||||
tribunalRooms: 1,
|
||||
activeArbiterTasks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,13 +159,6 @@ describe('verify state helpers', () => {
|
||||
launchdLabel: 'com.ejclaw',
|
||||
logName: 'ejclaw',
|
||||
},
|
||||
{
|
||||
kind: 'codex',
|
||||
name: 'ejclaw-codex',
|
||||
description: 'Codex',
|
||||
launchdLabel: 'com.ejclaw.codex',
|
||||
logName: 'ejclaw-codex',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
@@ -97,15 +166,19 @@ describe('verify state helpers', () => {
|
||||
services,
|
||||
serviceDefs,
|
||||
'configured',
|
||||
{ discord: 'configured' },
|
||||
{
|
||||
discord: 'configured',
|
||||
'discord-review': 'configured',
|
||||
'discord-arbiter': 'configured',
|
||||
},
|
||||
2,
|
||||
{ codex: 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'success',
|
||||
configuredChannels: ['discord'],
|
||||
configuredChannels: ['discord', 'discord-review', 'discord-arbiter'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: false,
|
||||
reviewConfigured: true,
|
||||
servicesSummary: { ejclaw: 'running' },
|
||||
});
|
||||
});
|
||||
@@ -128,4 +201,95 @@ describe('verify state helpers', () => {
|
||||
servicesSummary: { ejclaw: 'stopped' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when only the review channel is configured', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{ 'discord-review': 'configured' },
|
||||
1,
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord-review'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: false,
|
||||
servicesSummary: { ejclaw: 'running' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when tribunal rooms exist but the reviewer channel is missing', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{ discord: 'configured' },
|
||||
1,
|
||||
{},
|
||||
{ tribunalRooms: 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord'],
|
||||
codexConfigured: false,
|
||||
reviewConfigured: false,
|
||||
tribunalRooms: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when arbiter work is pending but the arbiter channel is missing', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{
|
||||
discord: 'configured',
|
||||
'discord-review': 'configured',
|
||||
},
|
||||
1,
|
||||
{},
|
||||
{ tribunalRooms: 1, activeArbiterTasks: 1 },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
configuredChannels: ['discord', 'discord-review'],
|
||||
codexConfigured: true,
|
||||
reviewConfigured: false,
|
||||
activeArbiterTasks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails verification when legacy discord token names are still configured', () => {
|
||||
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
|
||||
|
||||
expect(
|
||||
buildVerifySummary(
|
||||
services,
|
||||
[],
|
||||
'configured',
|
||||
{
|
||||
discord: 'configured',
|
||||
'discord-review': 'configured',
|
||||
'discord-arbiter': 'configured',
|
||||
},
|
||||
1,
|
||||
{},
|
||||
{ legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'] },
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,16 +11,34 @@ import type { ServiceCheck } from './verify-services.js';
|
||||
export type CredentialsStatus = 'configured' | 'missing';
|
||||
export type VerifyStatus = 'success' | 'failed';
|
||||
|
||||
const LEGACY_DISCORD_TOKEN_KEYS = [
|
||||
'DISCORD_BOT_TOKEN',
|
||||
'DISCORD_CODEX_BOT_TOKEN',
|
||||
'DISCORD_REVIEW_BOT_TOKEN',
|
||||
'DISCORD_CLAUDE_BOT_TOKEN',
|
||||
'DISCORD_CODEX_MAIN_BOT_TOKEN',
|
||||
'DISCORD_CODEX_REVIEW_BOT_TOKEN',
|
||||
];
|
||||
|
||||
export interface RegisteredGroupsSummary {
|
||||
registeredGroups: number;
|
||||
groupsByAgent: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RoleRoutingRequirementsSummary {
|
||||
tribunalRooms: number;
|
||||
activeArbiterTasks: number;
|
||||
}
|
||||
|
||||
export interface VerifySummary extends RegisteredGroupsSummary {
|
||||
status: VerifyStatus;
|
||||
servicesSummary: Record<string, string>;
|
||||
configuredChannels: string[];
|
||||
channelAuth: Record<string, string>;
|
||||
legacyDiscordTokenKeys: string[];
|
||||
tribunalRooms: number;
|
||||
activeArbiterTasks: number;
|
||||
// Legacy status fields kept for backward-compatible setup output.
|
||||
codexConfigured: boolean;
|
||||
reviewConfigured: boolean;
|
||||
}
|
||||
@@ -38,18 +56,39 @@ export function detectCredentials(projectRoot: string): CredentialsStatus {
|
||||
}
|
||||
|
||||
export function detectChannelAuth(
|
||||
envVars = readEnvFile(['DISCORD_BOT_TOKEN']),
|
||||
envVars = readEnvFile([
|
||||
'DISCORD_OWNER_BOT_TOKEN',
|
||||
'DISCORD_REVIEWER_BOT_TOKEN',
|
||||
'DISCORD_ARBITER_BOT_TOKEN',
|
||||
]),
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const channelAuth: Record<string, string> = {};
|
||||
|
||||
if (processEnv.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
|
||||
const hasEnv = (key: string): boolean => !!(processEnv[key] || envVars[key]);
|
||||
|
||||
if (hasEnv('DISCORD_OWNER_BOT_TOKEN')) {
|
||||
channelAuth.discord = 'configured';
|
||||
}
|
||||
if (hasEnv('DISCORD_REVIEWER_BOT_TOKEN')) {
|
||||
channelAuth['discord-review'] = 'configured';
|
||||
}
|
||||
if (hasEnv('DISCORD_ARBITER_BOT_TOKEN')) {
|
||||
channelAuth['discord-arbiter'] = 'configured';
|
||||
}
|
||||
|
||||
return channelAuth;
|
||||
}
|
||||
|
||||
export function detectLegacyDiscordTokenKeys(
|
||||
envVars = readEnvFile(LEGACY_DISCORD_TOKEN_KEYS),
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
return LEGACY_DISCORD_TOKEN_KEYS.filter(
|
||||
(key) => !!(processEnv[key] || envVars[key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadRegisteredGroupsSummary(
|
||||
dbPath = path.join(STORE_DIR, 'messages.db'),
|
||||
): RegisteredGroupsSummary {
|
||||
@@ -88,6 +127,82 @@ export function loadRegisteredGroupsSummary(
|
||||
return { registeredGroups, groupsByAgent };
|
||||
}
|
||||
|
||||
export function loadRoleRoutingRequirementsSummary(
|
||||
dbPath = path.join(STORE_DIR, 'messages.db'),
|
||||
): RoleRoutingRequirementsSummary {
|
||||
let tribunalRooms = 0;
|
||||
let activeArbiterTasks = 0;
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return { tribunalRooms, activeArbiterTasks };
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
try {
|
||||
const roomModeRow = db
|
||||
.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
|
||||
)
|
||||
`,
|
||||
)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const arbiterRow = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count
|
||||
FROM paired_tasks
|
||||
WHERE status IN ('arbiter_requested', 'in_arbitration')
|
||||
`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
activeArbiterTasks = arbiterRow.count;
|
||||
} catch {
|
||||
activeArbiterTasks = 0;
|
||||
}
|
||||
|
||||
db.close();
|
||||
} catch {
|
||||
// Tables might not exist yet
|
||||
}
|
||||
|
||||
return { tribunalRooms, activeArbiterTasks };
|
||||
}
|
||||
|
||||
export function buildVerifySummary(
|
||||
services: ServiceCheck[],
|
||||
serviceDefs: ServiceDef[],
|
||||
@@ -95,22 +210,33 @@ export function buildVerifySummary(
|
||||
channelAuth: Record<string, string>,
|
||||
registeredGroups: number,
|
||||
groupsByAgent: Record<string, number>,
|
||||
options: {
|
||||
legacyDiscordTokenKeys?: string[];
|
||||
tribunalRooms?: number;
|
||||
activeArbiterTasks?: number;
|
||||
} = {},
|
||||
): VerifySummary {
|
||||
void serviceDefs;
|
||||
const configuredChannels = Object.keys(channelAuth);
|
||||
const allConfiguredServicesRunning = services.every(
|
||||
(service) => service.status === 'running',
|
||||
);
|
||||
const codexConfigured = serviceDefs.some(
|
||||
(service) => service.kind === 'codex',
|
||||
);
|
||||
const reviewConfigured = serviceDefs.some(
|
||||
(service) => service.kind === 'review',
|
||||
);
|
||||
const hasOwnerCapableChannel = 'discord' in channelAuth;
|
||||
const codexConfigured = 'discord-review' in channelAuth;
|
||||
const reviewConfigured = 'discord-arbiter' in channelAuth;
|
||||
const legacyDiscordTokenKeys = options.legacyDiscordTokenKeys ?? [];
|
||||
const tribunalRooms = options.tribunalRooms ?? 0;
|
||||
const activeArbiterTasks = options.activeArbiterTasks ?? 0;
|
||||
const reviewerConfigured = tribunalRooms === 0 || codexConfigured;
|
||||
const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured;
|
||||
|
||||
const status =
|
||||
allConfiguredServicesRunning &&
|
||||
credentials === 'configured' &&
|
||||
configuredChannels.length > 0 &&
|
||||
hasOwnerCapableChannel &&
|
||||
legacyDiscordTokenKeys.length === 0 &&
|
||||
reviewerConfigured &&
|
||||
arbiterConfigured &&
|
||||
registeredGroups > 0
|
||||
? 'success'
|
||||
: 'failed';
|
||||
@@ -125,6 +251,9 @@ export function buildVerifySummary(
|
||||
servicesSummary,
|
||||
configuredChannels,
|
||||
channelAuth,
|
||||
legacyDiscordTokenKeys,
|
||||
tribunalRooms,
|
||||
activeArbiterTasks,
|
||||
registeredGroups,
|
||||
groupsByAgent,
|
||||
codexConfigured,
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
* Step: verify — End-to-end health check of the full installation.
|
||||
* Replaces 09-verify.sh
|
||||
*
|
||||
* Supports the EJClaw service stack:
|
||||
* - ejclaw (Claude Code) — always checked
|
||||
* - ejclaw-codex (Codex) — checked when .env.codex exists
|
||||
* - ejclaw-review (Codex Review) — checked when .env.codex-review exists
|
||||
* Supports the unified EJClaw service:
|
||||
* - ejclaw — always checked
|
||||
*
|
||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||
*/
|
||||
import os from 'os';
|
||||
|
||||
import { logger } from '../src/logger.js';
|
||||
import {
|
||||
detectLegacyServiceIssues,
|
||||
formatLegacyServiceFailureMessage,
|
||||
} from './legacy-service-guard.js';
|
||||
import { getServiceManager } from './platform.js';
|
||||
import { getServiceDefs } from './service-defs.js';
|
||||
import { emitStatus } from './status.js';
|
||||
@@ -17,7 +21,9 @@ import {
|
||||
buildVerifySummary,
|
||||
detectChannelAuth,
|
||||
detectCredentials,
|
||||
detectLegacyDiscordTokenKeys,
|
||||
loadRegisteredGroupsSummary,
|
||||
loadRoleRoutingRequirementsSummary,
|
||||
} from './verify-state.js';
|
||||
import { getServiceChecks } from './verify-services.js';
|
||||
|
||||
@@ -34,18 +40,35 @@ export async function run(_args: string[]): Promise<void> {
|
||||
// 1. Check service statuses
|
||||
const serviceDefs = getServiceDefs(projectRoot);
|
||||
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
|
||||
const legacyServiceIssues = detectLegacyServiceIssues(
|
||||
projectRoot,
|
||||
mgr,
|
||||
os.homedir(),
|
||||
);
|
||||
|
||||
for (const svc of services) {
|
||||
logger.info({ service: svc.name, status: svc.status }, 'Service status');
|
||||
}
|
||||
for (const svc of legacyServiceIssues) {
|
||||
logger.error(
|
||||
{ service: svc.name, status: svc.status },
|
||||
'Legacy service detected during verification',
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = detectCredentials(projectRoot);
|
||||
const channelAuth = detectChannelAuth();
|
||||
const legacyDiscordTokenKeys = detectLegacyDiscordTokenKeys();
|
||||
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary();
|
||||
const { tribunalRooms, activeArbiterTasks } =
|
||||
loadRoleRoutingRequirementsSummary();
|
||||
const {
|
||||
status,
|
||||
status: baseStatus,
|
||||
servicesSummary,
|
||||
configuredChannels,
|
||||
legacyDiscordTokenKeys: legacyDiscordTokens,
|
||||
tribunalRooms: detectedTribunalRooms,
|
||||
activeArbiterTasks: detectedActiveArbiterTasks,
|
||||
codexConfigured,
|
||||
reviewConfigured,
|
||||
} = buildVerifySummary(
|
||||
@@ -55,17 +78,64 @@ export async function run(_args: string[]): Promise<void> {
|
||||
channelAuth,
|
||||
registeredGroups,
|
||||
groupsByAgent,
|
||||
{
|
||||
legacyDiscordTokenKeys,
|
||||
tribunalRooms,
|
||||
activeArbiterTasks,
|
||||
},
|
||||
);
|
||||
const legacyServicesSummary = Object.fromEntries(
|
||||
legacyServiceIssues.map((service) => [service.name, service.status]),
|
||||
);
|
||||
const status = legacyServiceIssues.length > 0 ? 'failed' : baseStatus;
|
||||
|
||||
logger.info({ status, channelAuth, servicesSummary }, 'Verification complete');
|
||||
logger.info(
|
||||
{
|
||||
status,
|
||||
channelAuth,
|
||||
legacyDiscordTokens,
|
||||
tribunalRooms: detectedTribunalRooms,
|
||||
activeArbiterTasks: detectedActiveArbiterTasks,
|
||||
servicesSummary,
|
||||
legacyServicesSummary,
|
||||
},
|
||||
'Verification complete',
|
||||
);
|
||||
if (legacyDiscordTokens.length > 0) {
|
||||
logger.error(
|
||||
{
|
||||
legacyDiscordTokens,
|
||||
migration:
|
||||
'Rename Discord bot tokens to DISCORD_OWNER_BOT_TOKEN / DISCORD_REVIEWER_BOT_TOKEN / DISCORD_ARBITER_BOT_TOKEN',
|
||||
},
|
||||
'Verification failed due to legacy service-based Discord bot token names',
|
||||
);
|
||||
}
|
||||
if (legacyServiceIssues.length > 0) {
|
||||
logger.error(
|
||||
{
|
||||
cleanup: formatLegacyServiceFailureMessage({
|
||||
projectRoot,
|
||||
serviceManager: mgr,
|
||||
homeDir: os.homedir(),
|
||||
services: legacyServiceIssues,
|
||||
}),
|
||||
},
|
||||
'Verification failed due to legacy multi-service install',
|
||||
);
|
||||
}
|
||||
|
||||
emitStatus('VERIFY', {
|
||||
SERVICES: JSON.stringify(servicesSummary),
|
||||
LEGACY_SERVICES: JSON.stringify(legacyServicesSummary),
|
||||
// Legacy field (keep for backward compatibility)
|
||||
SERVICE: services[0].status,
|
||||
CREDENTIALS: credentials,
|
||||
CONFIGURED_CHANNELS: configuredChannels.join(','),
|
||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||
LEGACY_DISCORD_TOKENS: legacyDiscordTokens.join(','),
|
||||
TRIBUNAL_ROOMS: detectedTribunalRooms,
|
||||
ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks,
|
||||
REGISTERED_GROUPS: registeredGroups,
|
||||
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
|
||||
CODEX_CONFIGURED: codexConfigured,
|
||||
|
||||
Reference in New Issue
Block a user