From ca578d1627b893c82973010b2cc90476bbe86066 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 29 Mar 2026 07:08:01 +0900 Subject: [PATCH] feat: add stack restart orchestration --- CLAUDE.md | 13 ++++--- README.md | 11 ++++-- package.json | 1 + scripts/restart-ejclaw-stack.sh | 8 ++++ setup/restart-stack.test.ts | 69 +++++++++++++++++++++++++++++++++ setup/restart-stack.ts | 63 ++++++++++++++++++++++++++++++ setup/service-defs.ts | 62 +++++++++++++++++++++++++++++ setup/service.test.ts | 31 ++++++++++++++- setup/service.ts | 65 +++++++------------------------ setup/verify.ts | 46 +++++++++------------- 10 files changed, 281 insertions(+), 88 deletions(-) create mode 100755 scripts/restart-ejclaw-stack.sh create mode 100644 setup/restart-stack.test.ts create mode 100644 setup/restart-stack.ts create mode 100644 setup/service-defs.ts diff --git a/CLAUDE.md b/CLAUDE.md index 46e8202..b96e285 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,19 +45,21 @@ npm run dev # Dev mode with hot reload Service management (Linux): ```bash -systemctl --user restart ejclaw ejclaw-codex # Restart both +npm run restart:stack # Restart the configured stack and verify it +systemctl --user restart ejclaw ejclaw-codex ejclaw-review systemctl --user status ejclaw # Check status journalctl --user -u ejclaw -f # Follow logs ``` Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/EJClaw/dist/` -## Dual-Service Architecture +## Service Stack Architecture - `ejclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` -- `ejclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` -- Both share the same codebase (`dist/index.js`), differentiated by env vars -- Unified dirs (`store/`, `groups/`, `data/` shared by both services): +- `ejclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex-main`, `SERVICE_AGENT_TYPE=codex` +- `ejclaw-review.service` — Codex reviewer bot (`@codex-review`), `SERVICE_ID=codex-review`, `SERVICE_AGENT_TYPE=codex` +- All services share the same codebase (`dist/index.js`), differentiated by env vars +- Unified dirs (`store/`, `groups/`, `data/` shared by all services): - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) - `sessions`: composite PK `(group_folder, agent_type)` - `registered_groups`: filtered by `agent_type` on load @@ -72,6 +74,7 @@ Unified DB + directories (both services share `store/`, `groups/`, `data/`): | **DB** | `store/messages.db` (공유, WAL 모드) | | 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` | | 서비스 로그 (Codex) | `journalctl --user -u ejclaw-codex -f` 또는 `logs/ejclaw-codex.log` | +| 서비스 로그 (Review) | `journalctl --user -u ejclaw-review -f` 또는 `logs/ejclaw-review.log` | | 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | | Claude 세션 | `data/sessions/{name}/.claude/` | | Codex 세션 | `data/sessions/{name}/.codex/` | diff --git a/README.md b/README.md index 40b87e7..5cdd2dc 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,12 @@ To run the Codex agent alongside Claude, create `.env.codex`: ```bash # .env.codex DISCORD_BOT_TOKEN= # Separate Discord bot token for Codex + +# .env.codex-review +DISCORD_BOT_TOKEN= # Separate Discord bot token for Codex Review ``` -The setup step (`npm run setup -- --step service`) auto-detects `.env.codex` and installs `ejclaw-codex` alongside `ejclaw`. Additional Codex settings (`CODEX_MODEL`, `CODEX_EFFORT`, etc.) can be added to `.env.codex` or as `Environment=` lines in the systemd unit. +The setup step (`npm run setup -- --step service`) auto-detects `.env.codex` and `.env.codex-review`, then installs `ejclaw-codex` and `ejclaw-review` alongside `ejclaw`. Additional Codex settings (`CODEX_MODEL`, `CODEX_EFFORT`, etc.) can be added to `.env.codex`, `.env.codex-review`, or as `Environment=` lines in the systemd units. ### Authentication @@ -104,12 +107,14 @@ Token auto-refresh runs on the Claude service only, refreshing access tokens 30 ### Systemd Services (Linux) ```bash -systemctl --user enable ejclaw ejclaw-codex -systemctl --user start ejclaw ejclaw-codex +npm run restart:stack +systemctl --user enable ejclaw ejclaw-codex ejclaw-review +systemctl --user start ejclaw ejclaw-codex ejclaw-review # Logs journalctl --user -u ejclaw -f journalctl --user -u ejclaw-codex -f +journalctl --user -u ejclaw-review -f ``` ## Development diff --git a/package.json b/package.json index d347478..d615e3c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "tsc", "build:runners": "npm --prefix runners/agent-runner install && npm --prefix runners/agent-runner run build && npm --prefix runners/codex-runner install && npm --prefix runners/codex-runner run build", + "restart:stack": "bash scripts/restart-ejclaw-stack.sh", "restart:hint": "tsx src/restart-context-cli.ts write", "start": "node dist/index.js", "dev": "tsx src/index.ts", diff --git a/scripts/restart-ejclaw-stack.sh b/scripts/restart-ejclaw-stack.sh new file mode 100755 index 0000000..b8b3f76 --- /dev/null +++ b/scripts/restart-ejclaw-stack.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${PROJECT_ROOT}" +npx tsx setup/restart-stack.ts "$@" diff --git a/setup/restart-stack.test.ts b/setup/restart-stack.test.ts new file mode 100644 index 0000000..2e94701 --- /dev/null +++ b/setup/restart-stack.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { restartStackServices } from './restart-stack.js'; + +describe('restartStackServices', () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('restarts and verifies the configured three-service stack', () => { + 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(); + + const services = restartStackServices(tempRoot, { + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + }); + + expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(execFileSyncImpl).toHaveBeenNthCalledWith( + 1, + 'systemctl', + ['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'], + { stdio: 'ignore' }, + ); + expect(execFileSyncImpl).toHaveBeenNthCalledWith( + 2, + 'systemctl', + ['--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' }, + ); + }); + + it('rejects non-systemd environments', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-')); + tempRoots.push(tempRoot); + + expect(() => + restartStackServices(tempRoot, { + serviceManager: 'nohup', + }), + ).toThrow('restart:stack only supports Linux systemd services in this repo'); + }); +}); diff --git a/setup/restart-stack.ts b/setup/restart-stack.ts new file mode 100644 index 0000000..9e30619 --- /dev/null +++ b/setup/restart-stack.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'child_process'; +import { pathToFileURL } from 'url'; + +import { getServiceManager, isRoot } from './platform.js'; +import { getConfiguredServiceNames } from './service-defs.js'; + +type ServiceManager = ReturnType; + +interface RestartStackDeps { + execFileSyncImpl?: typeof execFileSync; + runningAsRoot?: boolean; + serviceManager?: ServiceManager; +} + +export function restartStackServices( + projectRoot: string, + deps: RestartStackDeps = {}, +): string[] { + const serviceManager = deps.serviceManager ?? getServiceManager(); + if (serviceManager !== 'systemd') { + throw new Error( + 'restart:stack only supports Linux systemd services in this repo', + ); + } + + const services = getConfiguredServiceNames(projectRoot); + if (services.length === 0) { + throw new Error('No EJClaw services are configured in this project'); + } + + const execImpl = deps.execFileSyncImpl ?? execFileSync; + const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; + + execImpl('systemctl', [...systemctlArgs, 'restart', ...services], { + stdio: 'ignore', + }); + + for (const service of services) { + execImpl('systemctl', [...systemctlArgs, 'is-active', '--quiet', service], { + stdio: 'ignore', + }); + } + + return services; +} + +export async function run(_args: string[]): Promise { + const services = restartStackServices(process.cwd()); + console.log(`Restarted and verified: ${services.join(', ')}`); +} + +if (process.argv[1]) { + const isMain = + import.meta.url === pathToFileURL(process.argv[1]).href || + import.meta.url === pathToFileURL(process.argv[1]).toString(); + if (isMain) { + run([]).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); + }); + } +} diff --git a/setup/service-defs.ts b/setup/service-defs.ts new file mode 100644 index 0000000..2edef05 --- /dev/null +++ b/setup/service-defs.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; + +export interface ServiceDef { + /** systemd unit name / nohup script name */ + name: string; + /** launchd label */ + launchdLabel: string; + /** Human-readable description for systemd/launchd */ + description: string; + /** Log file prefix (e.g. "ejclaw" → logs/ejclaw.log) */ + logName: string; + /** Absolute path to EnvironmentFile (systemd) — loaded before Environment= */ + environmentFile?: string; + /** Extra Environment= lines for systemd / env dict entries for launchd */ + extraEnv?: Record; +} + +export function getServiceDefs(projectRoot: string): ServiceDef[] { + const defs: ServiceDef[] = [ + { + name: 'ejclaw', + launchdLabel: 'com.ejclaw', + description: 'EJClaw Personal Assistant (Claude Code)', + logName: 'ejclaw', + }, + ]; + + const codexEnvPath = path.join(projectRoot, '.env.codex'); + if (fs.existsSync(codexEnvPath)) { + defs.push({ + name: 'ejclaw-codex', + launchdLabel: 'com.ejclaw-codex', + description: 'EJClaw Codex Assistant', + logName: 'ejclaw-codex', + environmentFile: codexEnvPath, + extraEnv: { + ASSISTANT_NAME: 'codex', + }, + }); + } + + const reviewEnvPath = path.join(projectRoot, '.env.codex-review'); + if (fs.existsSync(reviewEnvPath)) { + defs.push({ + name: 'ejclaw-review', + launchdLabel: 'com.ejclaw-review', + description: 'EJClaw Codex Review Assistant', + logName: 'ejclaw-review', + environmentFile: reviewEnvPath, + extraEnv: { + ASSISTANT_NAME: 'codex', + }, + }); + } + + return defs; +} + +export function getConfiguredServiceNames(projectRoot: string): string[] { + return getServiceDefs(projectRoot).map((def) => def.name); +} diff --git a/setup/service.test.ts b/setup/service.test.ts index 3458be0..0594a3c 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getServiceDefs } from './service-defs.js'; /** * Tests for service configuration generation. @@ -174,3 +178,28 @@ echo $! > ${JSON.stringify(pidFile)}`; expect(wrapper).toContain('ejclaw.pid'); }); }); + +describe('service definitions', () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('includes the review service when .env.codex-review exists', () => { + 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', + 'ejclaw-codex', + 'ejclaw-review', + ]); + }); +}); diff --git a/setup/service.ts b/setup/service.ts index dc6b76d..666ca9c 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -2,9 +2,10 @@ * Step: service — Generate and load service manager config. * Replaces 08-setup-service.sh * - * Supports dual-service architecture: + * 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 */ import { execSync } from 'child_process'; import fs from 'fs'; @@ -16,61 +17,11 @@ import { getPlatform, getNodePath, getServiceManager, - hasSystemd, isRoot, - isWSL, } from './platform.js'; +import { getServiceDefs, type ServiceDef } from './service-defs.js'; import { emitStatus } from './status.js'; -/* ------------------------------------------------------------------ */ -/* Service definition */ -/* ------------------------------------------------------------------ */ - -interface ServiceDef { - /** systemd unit name / nohup script name */ - name: string; - /** launchd label */ - launchdLabel: string; - /** Human-readable description for systemd/launchd */ - description: string; - /** Log file prefix (e.g. "ejclaw" → logs/ejclaw.log) */ - logName: string; - /** Absolute path to EnvironmentFile (systemd) — loaded before Environment= */ - environmentFile?: string; - /** Extra Environment= lines for systemd / env dict entries for launchd */ - extraEnv?: Record; -} - -function getServiceDefs(projectRoot: string): ServiceDef[] { - const defs: ServiceDef[] = [ - { - name: 'ejclaw', - launchdLabel: 'com.ejclaw', - description: 'EJClaw Personal Assistant (Claude Code)', - logName: 'ejclaw', - }, - ]; - - const codexEnvPath = path.join(projectRoot, '.env.codex'); - if (fs.existsSync(codexEnvPath)) { - defs.push({ - name: 'ejclaw-codex', - launchdLabel: 'com.ejclaw-codex', - description: 'EJClaw Codex Assistant', - logName: 'ejclaw-codex', - environmentFile: codexEnvPath, - extraEnv: { - ASSISTANT_NAME: 'codex', - }, - }); - logger.info( - 'Detected .env.codex — will also install ejclaw-codex service', - ); - } - - return defs; -} - /* ------------------------------------------------------------------ */ /* Entry point */ /* ------------------------------------------------------------------ */ @@ -107,6 +58,16 @@ export async function run(_args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); const serviceDefs = getServiceDefs(projectRoot); + if (serviceDefs.some((def) => def.name === 'ejclaw-codex')) { + logger.info( + 'Detected .env.codex — will also install ejclaw-codex service', + ); + } + if (serviceDefs.some((def) => def.name === 'ejclaw-review')) { + logger.info( + 'Detected .env.codex-review — will also install ejclaw-review service', + ); + } if (platform === 'macos') { for (const def of serviceDefs) { diff --git a/setup/verify.ts b/setup/verify.ts index 049ee41..a6dd1ab 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -2,9 +2,10 @@ * Step: verify — End-to-end health check of the full installation. * Replaces 09-verify.sh * - * Supports dual-service architecture: + * 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 * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ @@ -18,6 +19,7 @@ import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { logger } from '../src/logger.js'; import { getPlatform, getServiceManager, isRoot } from './platform.js'; +import { getServiceDefs } from './service-defs.js'; import { emitStatus } from './status.js'; /* ------------------------------------------------------------------ */ @@ -113,28 +115,11 @@ export async function run(_args: string[]): Promise { logger.info('Starting verification'); // 1. Check service statuses - const services: ServiceCheck[] = []; - - // Primary service (always checked) - services.push({ - name: 'ejclaw', - status: checkService(projectRoot, mgr, 'ejclaw', 'com.ejclaw'), - }); - - // Codex service (checked when .env.codex exists) - const codexEnvPath = path.join(projectRoot, '.env.codex'); - const codexConfigured = fs.existsSync(codexEnvPath); - if (codexConfigured) { - services.push({ - name: 'ejclaw-codex', - status: checkService( - projectRoot, - mgr, - 'ejclaw-codex', - 'com.ejclaw-codex', - ), - }); - } + const serviceDefs = getServiceDefs(projectRoot); + const services: ServiceCheck[] = serviceDefs.map((def) => ({ + name: def.name, + status: checkService(projectRoot, mgr, def.name, def.launchdLabel), + })); for (const svc of services) { logger.info({ service: svc.name, status: svc.status }, 'Service status'); @@ -195,12 +180,18 @@ export async function run(_args: string[]): Promise { } // Determine overall status - const primaryRunning = services[0].status === 'running'; - const codexOk = !codexConfigured || services[1]?.status === 'running'; + const allConfiguredServicesRunning = services.every( + (service) => service.status === 'running', + ); + const codexConfigured = serviceDefs.some( + (service) => service.name === 'ejclaw-codex', + ); + const reviewConfigured = serviceDefs.some( + (service) => service.name === 'ejclaw-review', + ); const status = - primaryRunning && - codexOk && + allConfiguredServicesRunning && credentials !== 'missing' && anyChannelConfigured && registeredGroups > 0 @@ -225,6 +216,7 @@ export async function run(_args: string[]): Promise { REGISTERED_GROUPS: registeredGroups, GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), CODEX_CONFIGURED: codexConfigured, + REVIEW_CONFIGURED: reviewConfigured, STATUS: status, LOG: 'logs/setup.log', });