diff --git a/CLAUDE.md b/CLAUDE.md index b96e285..7681869 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,9 @@ systemctl --user status ejclaw # Check status journalctl --user -u ejclaw -f # Follow logs ``` +`npm run restart:stack` is an external deploy/operator entrypoint. Do not invoke it from inside a managed EJClaw service process. +If you pulled this slice onto an existing install, run `npm run setup -- --step service` once so `ejclaw-stack-restart.service` is installed. + Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/EJClaw/dist/` ## Service Stack Architecture diff --git a/README.md b/README.md index 5cdd2dc..811bbdf 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ journalctl --user -u ejclaw-codex -f journalctl --user -u ejclaw-review -f ``` +Use `npm run restart:stack` from an external shell or deploy wrapper, not from inside one of the managed EJClaw services. +On existing installs, run `npm run setup -- --step service` once after pulling this slice so the `ejclaw-stack-restart.service` unit is installed. + ## Development ```bash diff --git a/setup/restart-stack.test.ts b/setup/restart-stack.test.ts index 2e94701..ce2d397 100644 --- a/setup/restart-stack.test.ts +++ b/setup/restart-stack.test.ts @@ -4,7 +4,10 @@ import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { restartStackServices } from './restart-stack.js'; +import { + STACK_RESTART_UNIT_NAME, + restartStackServices, +} from './restart-stack.js'; describe('restartStackServices', () => { const tempRoots: string[] = []; @@ -15,7 +18,7 @@ describe('restartStackServices', () => { } }); - it('restarts and verifies the configured three-service stack', () => { + 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'); @@ -27,6 +30,33 @@ describe('restartStackServices', () => { execFileSyncImpl, runningAsRoot: false, serviceManager: 'systemd', + serviceId: null, + }); + + expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(execFileSyncImpl).toHaveBeenNthCalledWith( + 1, + 'systemctl', + ['--user', 'start', '--wait', STACK_RESTART_UNIT_NAME], + { stdio: 'ignore' }, + ); + expect(execFileSyncImpl).toHaveBeenCalledTimes(1); + }); + + it('restarts and verifies the configured three-service stack 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(); + + const services = restartStackServices(tempRoot, { + direct: true, + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + serviceId: null, }); expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); @@ -66,4 +96,89 @@ describe('restartStackServices', () => { }), ).toThrow('restart:stack only supports Linux systemd services in this repo'); }); + + 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() + .mockImplementationOnce(() => { + const error = new Error('unit missing'); + Object.assign(error, { + stderr: 'Unit ejclaw-stack-restart.service not found.', + }); + throw error; + }); + + const services = restartStackServices(tempRoot, { + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + serviceId: null, + }); + + expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']); + expect(execFileSyncImpl).toHaveBeenNthCalledWith( + 1, + 'systemctl', + ['--user', 'start', '--wait', STACK_RESTART_UNIT_NAME], + { stdio: 'ignore' }, + ); + expect(execFileSyncImpl).toHaveBeenNthCalledWith( + 2, + 'systemctl', + ['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'], + { stdio: 'ignore' }, + ); + }); + + 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'); + Object.assign(error, { + stderr: 'Unit ejclaw-stack-restart.service not found.', + }); + throw error; + }); + + expect(() => + restartStackServices(tempRoot, { + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + serviceId: 'codex-main', + }), + ).toThrow('Run `npm run setup -- --step service`'); + }); + + 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'); + Object.assign(error, { + stderr: 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.', + }); + throw error; + }); + + expect(() => + restartStackServices(tempRoot, { + execFileSyncImpl, + runningAsRoot: false, + serviceManager: 'systemd', + serviceId: null, + }), + ).toThrow('job failed'); + expect(execFileSyncImpl).toHaveBeenCalledTimes(1); + }); }); diff --git a/setup/restart-stack.ts b/setup/restart-stack.ts index 9e30619..edff367 100644 --- a/setup/restart-stack.ts +++ b/setup/restart-stack.ts @@ -5,14 +5,21 @@ import { getServiceManager, isRoot } from './platform.js'; import { getConfiguredServiceNames } from './service-defs.js'; type ServiceManager = ReturnType; +export const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; +const UNIT_NOT_FOUND_PATTERN = + /(Unit .* not found|Could not find the requested service|not-found)/i; +const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = + 'Stack restart unit is not installed yet. Run `npm run setup -- --step service` from an external shell before retrying from a managed EJClaw service.'; interface RestartStackDeps { execFileSyncImpl?: typeof execFileSync; runningAsRoot?: boolean; serviceManager?: ServiceManager; + direct?: boolean; + serviceId?: string | null; } -export function restartStackServices( +function restartStackServicesDirect( projectRoot: string, deps: RestartStackDeps = {}, ): string[] { @@ -44,9 +51,73 @@ export function restartStackServices( return services; } -export async function run(_args: string[]): Promise { - const services = restartStackServices(process.cwd()); - console.log(`Restarted and verified: ${services.join(', ')}`); +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); + const execImpl = deps.execFileSyncImpl ?? execFileSync; + const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user']; + + if (deps.direct) { + return restartStackServicesDirect(projectRoot, deps); + } + + try { + execImpl( + 'systemctl', + [...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME], + { + stdio: 'ignore', + }, + ); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr || '') + : ''; + const stdout = + error && typeof error === 'object' && 'stdout' in error + ? String((error as { stdout?: unknown }).stdout || '') + : ''; + const message = + error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim(); + const combined = `${message}\n${stdout}\n${stderr}`; + const unitMissing = UNIT_NOT_FOUND_PATTERN.test(combined); + const callerServiceId = + deps.serviceId === undefined ? process.env.SERVICE_ID : deps.serviceId; + const managedServiceCaller = Boolean(callerServiceId); + + if (!unitMissing) { + throw error; + } + if (managedServiceCaller) { + throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE); + } + + return restartStackServicesDirect(projectRoot, deps); + } + + return services; +} + +export async function run(args: string[]): Promise { + const direct = args.includes('--direct'); + const services = restartStackServices(process.cwd(), { direct }); + if (direct) { + console.log(`Restarted and verified: ${services.join(', ')}`); + return; + } + console.log( + `Restarted and verified via ${STACK_RESTART_UNIT_NAME}: ${services.join(', ')}`, + ); } if (process.argv[1]) { diff --git a/setup/service.test.ts b/setup/service.test.ts index 0594a3c..5fead1c 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; +import { buildStackRestartSystemdUnit } from './service.js'; import { getServiceDefs } from './service-defs.js'; /** @@ -202,4 +203,18 @@ describe('service definitions', () => { 'ejclaw-review', ]); }); + + it('generates a oneshot stack restart unit', () => { + const unit = buildStackRestartSystemdUnit( + '/srv/ejclaw', + '/usr/bin/node', + '/home/user', + ); + + expect(unit).toContain('Description=EJClaw Stack Restart Orchestrator'); + expect(unit).toContain('Type=oneshot'); + expect(unit).toContain( + 'ExecStart=/bin/bash /srv/ejclaw/scripts/restart-ejclaw-stack.sh --direct', + ); + }); }); diff --git a/setup/service.ts b/setup/service.ts index 666ca9c..d5a1804 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -19,6 +19,7 @@ import { getServiceManager, isRoot, } from './platform.js'; +import { STACK_RESTART_UNIT_NAME } from './restart-stack.js'; import { getServiceDefs, type ServiceDef } from './service-defs.js'; import { emitStatus } from './status.js'; @@ -256,6 +257,7 @@ function setupSystemdAll( for (const def of serviceDefs) { setupSystemdUnit(def, projectRoot, nodePath, homeDir, runningAsRoot); } + setupSystemdStackRestartUnit(projectRoot, nodePath, homeDir, runningAsRoot); // Reload daemon once after all units are written try { @@ -366,6 +368,50 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; logger.info({ unitPath, service: def.name }, 'Wrote systemd unit'); } +export function buildStackRestartSystemdUnit( + projectRoot: string, + nodePath: string, + homeDir: string, +): string { + const envLines = [ + `Environment=HOME=${homeDir}`, + `Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin`, + ]; + + return `[Unit] +Description=EJClaw Stack Restart Orchestrator +After=network.target + +[Service] +Type=oneshot +WorkingDirectory=${projectRoot} +${envLines.join('\n')} +ExecStart=/bin/bash ${projectRoot}/scripts/restart-ejclaw-stack.sh --direct +`; +} + +function setupSystemdStackRestartUnit( + projectRoot: string, + nodePath: string, + homeDir: string, + runningAsRoot: boolean, +): void { + const unitPath = getUnitPath( + STACK_RESTART_UNIT_NAME.replace(/\.service$/, ''), + homeDir, + runningAsRoot, + ); + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + fs.writeFileSync( + unitPath, + buildStackRestartSystemdUnit(projectRoot, nodePath, homeDir), + ); + logger.info( + { unitPath, service: STACK_RESTART_UNIT_NAME }, + 'Wrote stack restart systemd unit', + ); +} + /* ------------------------------------------------------------------ */ /* nohup fallback (WSL / no systemd) */ /* ------------------------------------------------------------------ */