feat: add stack restart orchestration

This commit is contained in:
Eyejoker
2026-03-29 07:08:01 +09:00
parent 0e12a560a4
commit ca578d1627
10 changed files with 281 additions and 88 deletions

View File

@@ -45,19 +45,21 @@ npm run dev # Dev mode with hot reload
Service management (Linux): Service management (Linux):
```bash ```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 systemctl --user status ejclaw # Check status
journalctl --user -u ejclaw -f # Follow logs journalctl --user -u ejclaw -f # Follow logs
``` ```
Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/EJClaw/dist/` 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.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` - `ejclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex-main`, `SERVICE_AGENT_TYPE=codex`
- Both share the same codebase (`dist/index.js`), differentiated by env vars - `ejclaw-review.service` — Codex reviewer bot (`@codex-review`), `SERVICE_ID=codex-review`, `SERVICE_AGENT_TYPE=codex`
- Unified dirs (`store/`, `groups/`, `data/` shared by both services): - 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`) - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`)
- `sessions`: composite PK `(group_folder, agent_type)` - `sessions`: composite PK `(group_folder, agent_type)`
- `registered_groups`: filtered by `agent_type` on load - `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 모드) | | **DB** | `store/messages.db` (공유, WAL 모드) |
| 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` | | 서비스 로그 (Claude) | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` |
| 서비스 로그 (Codex) | `journalctl --user -u ejclaw-codex -f` 또는 `logs/ejclaw-codex.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/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | | 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
| Claude 세션 | `data/sessions/{name}/.claude/` | | Claude 세션 | `data/sessions/{name}/.claude/` |
| Codex 세션 | `data/sessions/{name}/.codex/` | | Codex 세션 | `data/sessions/{name}/.codex/` |

View File

@@ -91,9 +91,12 @@ To run the Codex agent alongside Claude, create `.env.codex`:
```bash ```bash
# .env.codex # .env.codex
DISCORD_BOT_TOKEN= # Separate Discord bot token for 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 ### Authentication
@@ -104,12 +107,14 @@ Token auto-refresh runs on the Claude service only, refreshing access tokens 30
### Systemd Services (Linux) ### Systemd Services (Linux)
```bash ```bash
systemctl --user enable ejclaw ejclaw-codex npm run restart:stack
systemctl --user start ejclaw ejclaw-codex systemctl --user enable ejclaw ejclaw-codex ejclaw-review
systemctl --user start ejclaw ejclaw-codex ejclaw-review
# Logs # Logs
journalctl --user -u ejclaw -f journalctl --user -u ejclaw -f
journalctl --user -u ejclaw-codex -f journalctl --user -u ejclaw-codex -f
journalctl --user -u ejclaw-review -f
``` ```
## Development ## Development

View File

@@ -7,6 +7,7 @@
"scripts": { "scripts": {
"build": "tsc", "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", "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", "restart:hint": "tsx src/restart-context-cli.ts write",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",

View File

@@ -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 "$@"

View File

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

63
setup/restart-stack.ts Normal file
View File

@@ -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<typeof getServiceManager>;
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<void> {
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);
});
}
}

62
setup/service-defs.ts Normal file
View File

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

View File

@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { getServiceDefs } from './service-defs.js';
/** /**
* Tests for service configuration generation. * Tests for service configuration generation.
@@ -174,3 +178,28 @@ echo $! > ${JSON.stringify(pidFile)}`;
expect(wrapper).toContain('ejclaw.pid'); 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',
]);
});
});

View File

@@ -2,9 +2,10 @@
* Step: service — Generate and load service manager config. * Step: service — Generate and load service manager config.
* Replaces 08-setup-service.sh * Replaces 08-setup-service.sh
* *
* Supports dual-service architecture: * Supports the EJClaw service stack:
* - ejclaw (Claude Code) — always installed * - ejclaw (Claude Code) — always installed
* - ejclaw-codex (Codex) — installed when .env.codex exists * - ejclaw-codex (Codex) — installed when .env.codex exists
* - ejclaw-review (Codex Review) — installed when .env.codex-review exists
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
@@ -16,61 +17,11 @@ import {
getPlatform, getPlatform,
getNodePath, getNodePath,
getServiceManager, getServiceManager,
hasSystemd,
isRoot, isRoot,
isWSL,
} from './platform.js'; } from './platform.js';
import { getServiceDefs, type ServiceDef } from './service-defs.js';
import { emitStatus } from './status.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<string, string>;
}
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 */ /* Entry point */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -107,6 +58,16 @@ export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
const serviceDefs = getServiceDefs(projectRoot); 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') { if (platform === 'macos') {
for (const def of serviceDefs) { for (const def of serviceDefs) {

View File

@@ -2,9 +2,10 @@
* Step: verify — End-to-end health check of the full installation. * Step: verify — End-to-end health check of the full installation.
* Replaces 09-verify.sh * Replaces 09-verify.sh
* *
* Supports dual-service architecture: * Supports the EJClaw service stack:
* - ejclaw (Claude Code) — always checked * - ejclaw (Claude Code) — always checked
* - ejclaw-codex (Codex) — checked when .env.codex exists * - 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. * 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 { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { getPlatform, getServiceManager, isRoot } from './platform.js'; import { getPlatform, getServiceManager, isRoot } from './platform.js';
import { getServiceDefs } from './service-defs.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -113,28 +115,11 @@ export async function run(_args: string[]): Promise<void> {
logger.info('Starting verification'); logger.info('Starting verification');
// 1. Check service statuses // 1. Check service statuses
const services: ServiceCheck[] = []; const serviceDefs = getServiceDefs(projectRoot);
const services: ServiceCheck[] = serviceDefs.map((def) => ({
// Primary service (always checked) name: def.name,
services.push({ status: checkService(projectRoot, mgr, def.name, def.launchdLabel),
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',
),
});
}
for (const svc of services) { for (const svc of services) {
logger.info({ service: svc.name, status: svc.status }, 'Service status'); logger.info({ service: svc.name, status: svc.status }, 'Service status');
@@ -195,12 +180,18 @@ export async function run(_args: string[]): Promise<void> {
} }
// Determine overall status // Determine overall status
const primaryRunning = services[0].status === 'running'; const allConfiguredServicesRunning = services.every(
const codexOk = !codexConfigured || services[1]?.status === 'running'; (service) => service.status === 'running',
);
const codexConfigured = serviceDefs.some(
(service) => service.name === 'ejclaw-codex',
);
const reviewConfigured = serviceDefs.some(
(service) => service.name === 'ejclaw-review',
);
const status = const status =
primaryRunning && allConfiguredServicesRunning &&
codexOk &&
credentials !== 'missing' && credentials !== 'missing' &&
anyChannelConfigured && anyChannelConfigured &&
registeredGroups > 0 registeredGroups > 0
@@ -225,6 +216,7 @@ export async function run(_args: string[]): Promise<void> {
REGISTERED_GROUPS: registeredGroups, REGISTERED_GROUPS: registeredGroups,
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent), GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
CODEX_CONFIGURED: codexConfigured, CODEX_CONFIGURED: codexConfigured,
REVIEW_CONFIGURED: reviewConfigured,
STATUS: status, STATUS: status,
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });