diff --git a/.crew/amos.yaml b/.crew/amos.yaml index bfa25ac2..b7c0d922 100644 --- a/.crew/amos.yaml +++ b/.crew/amos.yaml @@ -3,6 +3,9 @@ scope: - lib/** - .crew/** - test/** + - deploy/** + - scripts/** + - package.json branch_conventions: feat: "feat/lr-{task_id}-{slug}" diff --git a/deploy/clagentic-console.service b/deploy/clagentic-console.service new file mode 100644 index 00000000..c792b14e --- /dev/null +++ b/deploy/clagentic-console.service @@ -0,0 +1,28 @@ +[Unit] +Description=Clagentic Console daemon +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/clagentic-daemon.sh +Restart=always +RestartSec=5 +KillMode=mixed +TimeoutStopSec=20 +SendSIGKILL=yes +# Soft ceiling — kernel begins reclaiming memory early (requires systemd 240+; host runs systemd 255) +MemoryHigh=70% +# Hard ceiling — triggers per-service OOM so OOMPolicy fires instead of host-level OOM killer +MemoryMax=85% +# On OOM, kill all remaining cgroup members so no orphaned child processes survive +OOMPolicy=kill +StandardOutput=journal +StandardError=journal +SyslogIdentifier=clagentic-console +Environment=SHELL=/bin/bash +Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# TODO(lr-9452): rename to CLAGENTIC_CONSOLE_MAX_CONCURRENT_SESSIONS once sdk-bridge.js is updated +Environment=CLAGENTIC_MAX_CONCURRENT_SESSIONS=50 + +[Install] +WantedBy=multi-user.target diff --git a/lib/daemon.js b/lib/daemon.js index ff3e7aab..a9c2a088 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -1381,6 +1381,9 @@ var ipc = createIPCServer(socketPath(), function (msg) { stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8", + // CLAGENTIC_SELF_UPDATE=1 tells postinstall.js not to restart the + // service — gracefulShutdown() below hands off to systemd Restart=always. + env: Object.assign({}, process.env, { CLAGENTIC_SELF_UPDATE: "1" }), }); console.log("[daemon] Global install complete. Shutting down for supervised restart."); updOk = true; diff --git a/package.json b/package.json index fefc1e0b..e0ba4b27 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,15 @@ }, "scripts": { "dev": "node bin/cli.js --dev", + "postinstall": "node scripts/postinstall.js", "test": "node --test --test-force-exit test/*.test.js", "semantic-release": "semantic-release" }, "files": [ "bin/", "lib/", + "scripts/", + "deploy/", "CREDITS.md" ], "keywords": [ diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 00000000..b0a1da9e --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +'use strict'; + +// postinstall.js — installs/updates the systemd service unit on Linux global npm installs. +// Runs automatically after `npm install -g @clagentic/console`. +// Silently exits on non-Linux platforms and non-root invocations. + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const PREFIX = '[clagentic-console postinstall]'; + +function log(msg) { + console.log(`${PREFIX} ${msg}`); +} + +function runCmd(cmd, args) { + // Returns stdout string on success, throws on failure. + // stderr is captured in err.stderr on failure. + return execFileSync(cmd, args, { stdio: 'pipe' }).toString().trim(); +} + +function errMsg(err) { + // execFileSync puts detail in err.stderr; fall back to err.message. + const stderr = err.stderr && err.stderr.toString().trim(); + return stderr || err.message; +} + +// Skip silently on non-Linux platforms. +if (process.platform !== 'linux') { + process.exit(0); +} + +// Skip silently when not running as root — cannot write to /etc/systemd/system/. +if (process.getuid() !== 0) { + log('skipping (not root)'); + process.exit(0); +} + +// When the daemon triggers its own in-app update it passes CLAGENTIC_SELF_UPDATE=1. +// In that case, skip restarting the service — the daemon calls gracefulShutdown() +// immediately after npm install completes, and systemd Restart=always handles the +// supervised restart. Issuing systemctl restart here would race with gracefulShutdown +// and tear down sessions before state is flushed. +const selfUpdate = process.env.CLAGENTIC_SELF_UPDATE === '1'; +if (selfUpdate) { + log('self-update detected (CLAGENTIC_SELF_UPDATE=1) — skipping service restart'); +} + +const SYSTEMD_DIR = '/etc/systemd/system'; +const NEW_UNIT = 'clagentic-console.service'; +const OLD_UNIT = 'clagentic.service'; +const NEW_UNIT_DEST = path.join(SYSTEMD_DIR, NEW_UNIT); +const OLD_UNIT_PATH = path.join(SYSTEMD_DIR, OLD_UNIT); +const UNIT_SRC = path.join(__dirname, '..', 'deploy', 'clagentic-console.service'); + +// Step 1: Copy the new unit file. +log(`installing unit file -> ${NEW_UNIT_DEST}`); +try { + fs.copyFileSync(UNIT_SRC, NEW_UNIT_DEST); +} catch (err) { + log(`ERROR copying unit file: ${errMsg(err)}`); + // Cannot continue without the unit file in place. + process.exit(0); +} + +// Step 2: daemon-reload to pick up the new unit file. +log('running systemctl daemon-reload'); +try { + runCmd('systemctl', ['daemon-reload']); +} catch (err) { + log(`WARNING: daemon-reload failed: ${errMsg(err)}`); +} + +// Step 3: Enable the new unit if not already enabled. +log(`enabling ${NEW_UNIT}`); +try { + runCmd('systemctl', ['enable', NEW_UNIT]); +} catch (err) { + log(`WARNING: enable ${NEW_UNIT} failed: ${errMsg(err)}`); +} + +// Step 4: Handle rename — migrate from old clagentic.service if present. +let wasRunningUnderOldUnit = false; +if (fs.existsSync(OLD_UNIT_PATH)) { + log(`old unit file detected: ${OLD_UNIT_PATH} — performing rename cutover`); + + // Detect whether the old unit is currently active before stopping it. + try { + const activeState = runCmd('systemctl', ['is-active', OLD_UNIT]); + if (activeState === 'active') { + wasRunningUnderOldUnit = true; + } + } catch (_) { + // is-active exits non-zero when not active — not an error. + } + + // Disable the old unit. + log(`disabling ${OLD_UNIT}`); + try { + runCmd('systemctl', ['disable', OLD_UNIT]); + } catch (err) { + log(`WARNING: disable ${OLD_UNIT} failed (ignored): ${errMsg(err)}`); + } + + // Stop the old unit. + log(`stopping ${OLD_UNIT}`); + try { + runCmd('systemctl', ['stop', OLD_UNIT]); + } catch (err) { + log(`WARNING: stop ${OLD_UNIT} failed (ignored): ${errMsg(err)}`); + } + + // Remove the old unit file. + log(`removing ${OLD_UNIT_PATH}`); + try { + fs.unlinkSync(OLD_UNIT_PATH); + } catch (err) { + log(`WARNING: remove ${OLD_UNIT_PATH} failed: ${errMsg(err)}`); + } + + // daemon-reload again to clear the old unit from systemd's view. + log('running systemctl daemon-reload (post-rename)'); + try { + runCmd('systemctl', ['daemon-reload']); + } catch (err) { + log(`WARNING: daemon-reload (post-rename) failed: ${errMsg(err)}`); + } +} + +// Step 5: Start or restart clagentic-console.service. +// Never restart during a self-update — gracefulShutdown() + Restart=always handles it. +if (selfUpdate) { + log('skipping start/restart (self-update — gracefulShutdown will hand off to systemd)'); +} else { + let newUnitActive = false; + try { + const activeState = runCmd('systemctl', ['is-active', NEW_UNIT]); + newUnitActive = activeState === 'active'; + } catch (_) { + // is-active exits non-zero when not active. + } + + if (newUnitActive) { + // Already running (manual operator update) — restart so new unit file changes + // (memory limits, env vars, etc.) take effect immediately. + log(`restarting ${NEW_UNIT} (already active — applying unit file changes)`); + try { + runCmd('systemctl', ['restart', NEW_UNIT]); + } catch (err) { + log(`WARNING: restart ${NEW_UNIT} failed: ${errMsg(err)}`); + } + } else if (wasRunningUnderOldUnit) { + // Was running under the old unit — start it under the new one now. + log(`starting ${NEW_UNIT} (was running under ${OLD_UNIT})`); + try { + runCmd('systemctl', ['start', NEW_UNIT]); + } catch (err) { + log(`WARNING: start ${NEW_UNIT} failed: ${errMsg(err)}`); + } + } else { + // Not previously running — do not auto-start. The daemon requires configuration + // before it can run; starting without config would loop under Restart=always. + log(`${NEW_UNIT} was not running — skipping auto-start (run: systemctl start ${NEW_UNIT})`); + } +} + +log('done');