omnikey-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,8 @@ const logger_1 = require("./logger");
15
15
  const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
16
16
  const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
17
17
  const mcpServerRoutes_1 = require("./mcpServerRoutes");
18
- const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
19
- const sessionGrouping_1 = require("./agent/sessionGrouping");
18
+ const spawn_1 = require("./workers/spawn");
19
+ const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
20
20
  const config_1 = require("./config");
21
21
  const agentServer_1 = require("./agent/agentServer");
22
22
  // Importing AgentSession and ScheduledJob ensures the models are registered with Sequelize before initDatabase().
@@ -185,6 +185,7 @@ app.get('*', (_req, res) => {
185
185
  res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
186
186
  });
187
187
  let server = null;
188
+ const backgroundWorkers = [];
188
189
  async function start() {
189
190
  try {
190
191
  await (0, db_1.initDatabase)(logger_1.logger);
@@ -201,8 +202,15 @@ async function start() {
201
202
  (0, agentServer_1.attachAgentWebSocketServer)(server);
202
203
  }
203
204
  if (config_1.config.isSelfHosted) {
204
- (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
205
- (0, sessionGrouping_1.startGroupingCronJob)();
205
+ // Run the schedulers in dedicated worker threads so their DB
206
+ // polling / cron ticks never block the HTTP event loop.
207
+ const scheduledJobWorker = (0, spawn_1.spawnWorker)('scheduledJobWorker');
208
+ backgroundWorkers.push(scheduledJobWorker);
209
+ backgroundWorkers.push((0, spawn_1.spawnWorker)('groupingWorker'));
210
+ // Expose the worker handle so HTTP routes (e.g. POST /:id/run-now)
211
+ // can dispatch immediate executions into the worker thread instead
212
+ // of running them in-process and blocking the event loop.
213
+ (0, scheduledJobWorkerClient_1.setScheduledJobWorker)(scheduledJobWorker);
206
214
  }
207
215
  }
208
216
  catch (err) {
@@ -211,21 +219,31 @@ async function start() {
211
219
  }
212
220
  }
213
221
  start();
222
+ async function stopBackgroundWorkers() {
223
+ if (!backgroundWorkers.length)
224
+ return;
225
+ logger_1.logger.info('Stopping background workers...', { count: backgroundWorkers.length });
226
+ (0, scheduledJobWorkerClient_1.setScheduledJobWorker)(null);
227
+ await Promise.allSettled(backgroundWorkers.map((w) => w.stop()));
228
+ }
214
229
  function gracefulShutdown(signal) {
215
230
  logger_1.logger.info(`Received ${signal}. Starting graceful shutdown...`);
231
+ const finish = (code) => {
232
+ void stopBackgroundWorkers().finally(() => process.exit(code));
233
+ };
216
234
  if (!server) {
217
235
  logger_1.logger.info('Server was not started or already closed. Exiting process.');
218
- process.exit(0);
236
+ finish(0);
219
237
  return;
220
238
  }
221
239
  server.close((err) => {
222
240
  if (err) {
223
241
  logger_1.logger.error('Error during HTTP server shutdown.', { error: err });
224
- process.exitCode = 1;
242
+ finish(1);
225
243
  return;
226
244
  }
227
245
  logger_1.logger.info('HTTP server closed. Exiting process.');
228
- process.exit(0);
246
+ finish(0);
229
247
  });
230
248
  }
231
249
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
@@ -9,6 +9,7 @@ const zod_1 = __importDefault(require("zod"));
9
9
  const authMiddleware_1 = require("./authMiddleware");
10
10
  const scheduledJob_1 = require("./models/scheduledJob");
11
11
  const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
12
+ const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
12
13
  const CRON_REGEX = /^(\S+\s){4}\S+$/;
13
14
  const jobSchema = zod_1.default.object({
14
15
  label: zod_1.default.string().min(1).max(200),
@@ -172,9 +173,19 @@ function scheduledJobRouter() {
172
173
  if (!job) {
173
174
  return res.status(404).json({ error: 'Scheduled job not found.' });
174
175
  }
175
- void (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
176
- logger.error('run-now execution failed.', { jobId: job.id, error: err });
177
- });
176
+ // Prefer the background worker (self-hosted) so the execution
177
+ // doesn't block this HTTP handler or any other request on the main
178
+ // event loop. Fall back to in-process execution when no worker is
179
+ // running (e.g. cloud deployments or during shutdown).
180
+ const dispatched = (0, scheduledJobWorkerClient_1.triggerJobInWorker)(job.id);
181
+ if (!dispatched) {
182
+ void (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
183
+ logger.error('run-now execution failed.', { jobId: job.id, error: err });
184
+ });
185
+ }
186
+ else {
187
+ logger.info('run-now dispatched to scheduledJobWorker.', { jobId: job.id });
188
+ }
178
189
  res.json(formatJob(job));
179
190
  }
180
191
  catch (err) {
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const workerBootstrap_1 = require("./workerBootstrap");
4
+ const sessionGrouping_1 = require("../agent/sessionGrouping");
5
+ void (0, workerBootstrap_1.bootstrapWorker)('groupingWorker', () => {
6
+ (0, sessionGrouping_1.startGroupingCronJob)();
7
+ });
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const scheduledJob_1 = require("../models/scheduledJob");
4
+ const scheduledJobExecutor_1 = require("../scheduledJobExecutor");
5
+ const logger_1 = require("../logger");
6
+ const workerBootstrap_1 = require("./workerBootstrap");
7
+ void (0, workerBootstrap_1.bootstrapWorker)('scheduledJobWorker', () => {
8
+ (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
9
+ }, {
10
+ onMessage: async (msg) => {
11
+ if (msg.type !== 'runJob')
12
+ return;
13
+ const job = await scheduledJob_1.ScheduledJob.findByPk(msg.jobId);
14
+ if (!job) {
15
+ logger_1.logger.warn('runJob: scheduled job not found in worker.', { jobId: msg.jobId });
16
+ return;
17
+ }
18
+ logger_1.logger.info('runJob: executing scheduled job inside worker.', {
19
+ jobId: job.id,
20
+ label: job.label,
21
+ });
22
+ // executeJob already guards against concurrent runs of the same jobId
23
+ // (via RUNNING_JOB_IDS) and updates lastRunAt / nextRunAt itself, so we
24
+ // just fire-and-forget here and let errors surface through its own
25
+ // logger.error call.
26
+ await (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
27
+ logger_1.logger.error('runJob: execution failed.', { jobId: job.id, error: err });
28
+ });
29
+ },
30
+ });
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setScheduledJobWorker = setScheduledJobWorker;
4
+ exports.triggerJobInWorker = triggerJobInWorker;
5
+ let scheduledJobWorker = null;
6
+ /**
7
+ * Register the scheduledJobWorker handle so HTTP routes can post messages to
8
+ * it. Called once from `index.ts` after the worker is spawned.
9
+ */
10
+ function setScheduledJobWorker(worker) {
11
+ scheduledJobWorker = worker;
12
+ }
13
+ /**
14
+ * Ask the scheduledJobWorker to execute a job immediately. Returns true if
15
+ * the message was dispatched, false if no worker is currently running (e.g.
16
+ * non-self-hosted deployments where the executor still runs in-process). The
17
+ * caller is expected to fall back to an in-process `executeJob` when this
18
+ * returns false.
19
+ */
20
+ function triggerJobInWorker(jobId) {
21
+ if (!scheduledJobWorker)
22
+ return false;
23
+ scheduledJobWorker.postMessage({ type: 'runJob', jobId });
24
+ return true;
25
+ }
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.spawnWorker = spawnWorker;
7
+ const path_1 = __importDefault(require("path"));
8
+ const worker_threads_1 = require("worker_threads");
9
+ const logger_1 = require("../logger");
10
+ // Distance (ms) between auto-restart attempts after an unexpected exit.
11
+ // Keeps a crashed worker from spinning a tight restart loop while still
12
+ // recovering quickly from transient errors.
13
+ const RESTART_DELAY_MS = 5000;
14
+ /**
15
+ * Resolve the on-disk path to a worker entry file.
16
+ *
17
+ * - Production / `yarn start`: the project is compiled to `dist/` and the
18
+ * running file's extension is `.js`. The compiled worker lives next to
19
+ * this file at `dist/workers/<name>.js`.
20
+ * - Development / `yarn dev`: ts-node-dev runs the original `.ts` sources.
21
+ * `__filename` ends with `.ts`, so we point the worker at the matching
22
+ * `.ts` file and rely on `ts-node/register` (loaded via `execArgv`) to
23
+ * transpile it on the fly inside the worker thread.
24
+ */
25
+ function resolveWorkerEntry(workerName) {
26
+ const isTs = __filename.endsWith('.ts');
27
+ const ext = isTs ? '.ts' : '.js';
28
+ const entry = path_1.default.join(__dirname, `${workerName}${ext}`);
29
+ return { entry, isTs };
30
+ }
31
+ /**
32
+ * Spawn a worker thread and automatically restart it if it exits unexpectedly.
33
+ *
34
+ * The returned handle exposes a `postMessage()` method for typed RPC and a
35
+ * `stop()` method that disables auto-restart before terminating the worker.
36
+ */
37
+ function spawnWorker(workerName) {
38
+ let stopped = false;
39
+ let restartTimer = null;
40
+ const handle = {
41
+ name: workerName,
42
+ // Replaced by start(); seeded with a placeholder so TS is happy.
43
+ worker: null,
44
+ postMessage: (msg) => {
45
+ const w = handle.worker;
46
+ if (!w) {
47
+ logger_1.logger.warn('Dropping message to worker that has not started yet.', {
48
+ workerName,
49
+ messageType: msg.type,
50
+ });
51
+ return;
52
+ }
53
+ try {
54
+ w.postMessage(msg);
55
+ }
56
+ catch (err) {
57
+ logger_1.logger.error('Failed to post message to worker.', {
58
+ workerName,
59
+ messageType: msg.type,
60
+ error: err,
61
+ });
62
+ }
63
+ },
64
+ stop: async () => {
65
+ stopped = true;
66
+ if (restartTimer) {
67
+ clearTimeout(restartTimer);
68
+ restartTimer = null;
69
+ }
70
+ const w = handle.worker;
71
+ if (!w)
72
+ return;
73
+ try {
74
+ await w.terminate();
75
+ }
76
+ catch (err) {
77
+ logger_1.logger.error('Failed to terminate worker.', { workerName, error: err });
78
+ }
79
+ },
80
+ };
81
+ const start = () => {
82
+ const { entry, isTs } = resolveWorkerEntry(workerName);
83
+ const execArgv = isTs ? ['-r', 'ts-node/register/transpile-only'] : [];
84
+ logger_1.logger.info('Spawning background worker.', { workerName, entry });
85
+ const worker = new worker_threads_1.Worker(entry, {
86
+ execArgv,
87
+ // Forward stdout/stderr through the parent so worker logs surface in the
88
+ // same console / log aggregator as the main process.
89
+ stdout: false,
90
+ stderr: false,
91
+ });
92
+ handle.worker = worker;
93
+ worker.on('error', (err) => {
94
+ logger_1.logger.error('Worker emitted error.', { workerName, error: err });
95
+ });
96
+ worker.on('exit', (code) => {
97
+ if (stopped) {
98
+ logger_1.logger.info('Worker exited after stop.', { workerName, code });
99
+ return;
100
+ }
101
+ logger_1.logger.error('Worker exited unexpectedly; scheduling restart.', {
102
+ workerName,
103
+ code,
104
+ restartDelayMs: RESTART_DELAY_MS,
105
+ });
106
+ restartTimer = setTimeout(() => {
107
+ restartTimer = null;
108
+ if (!stopped)
109
+ start();
110
+ }, RESTART_DELAY_MS);
111
+ });
112
+ };
113
+ start();
114
+ return handle;
115
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bootstrapWorker = bootstrapWorker;
4
+ const worker_threads_1 = require("worker_threads");
5
+ const db_1 = require("../db");
6
+ const logger_1 = require("../logger");
7
+ // Importing the models registers them with Sequelize before initDatabase()
8
+ // runs inside the worker. Workers share the same model definitions as the
9
+ // main process but each owns its own Sequelize connection.
10
+ require("../models/agentSession");
11
+ require("../models/scheduledJob");
12
+ require("../models/mcpServer");
13
+ /**
14
+ * Initialize a background worker thread.
15
+ *
16
+ * Each worker owns its own Sequelize connection (db.ts is evaluated fresh in
17
+ * the worker's V8 isolate), so DB calls performed here do not contend with
18
+ * the main process's connection pool or block the HTTP event loop.
19
+ *
20
+ * The `run` callback receives the initialized logger and should kick off any
21
+ * long-running schedulers. It must never throw — uncaught errors crash the
22
+ * worker, and the parent will auto-restart it after a short delay.
23
+ */
24
+ async function bootstrapWorker(workerName, run, options = {}) {
25
+ try {
26
+ await (0, db_1.initDatabase)(logger_1.logger);
27
+ logger_1.logger.info('Worker database connection ready.', { workerName });
28
+ await run();
29
+ logger_1.logger.info('Worker entry returned; scheduler is now active.', { workerName });
30
+ }
31
+ catch (err) {
32
+ logger_1.logger.error('Worker bootstrap failed.', { workerName, error: err });
33
+ // Exit non-zero so the parent's `exit` handler treats this as a crash
34
+ // and schedules a restart instead of silently leaving the worker dead.
35
+ process.exit(1);
36
+ }
37
+ worker_threads_1.parentPort?.on('message', (raw) => {
38
+ // Defensive: messages can technically be any structured-clone-safe value.
39
+ const msg = raw;
40
+ if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') {
41
+ logger_1.logger.warn('Worker received malformed message; ignoring.', { workerName, raw });
42
+ return;
43
+ }
44
+ if (msg.type === 'shutdown') {
45
+ logger_1.logger.info('Worker received shutdown signal; exiting.', { workerName });
46
+ process.exit(0);
47
+ }
48
+ if (options.onMessage) {
49
+ void (async () => {
50
+ try {
51
+ await options.onMessage(msg);
52
+ }
53
+ catch (err) {
54
+ logger_1.logger.error('Worker message handler threw.', {
55
+ workerName,
56
+ messageType: msg.type,
57
+ error: err,
58
+ });
59
+ }
60
+ })();
61
+ }
62
+ });
63
+ }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "version": "1.3.0",
7
+ "version": "1.4.0",
8
8
  "description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
9
9
  "engines": {
10
10
  "node": ">=14.0.0",