omnikey-cli 1.0.27 → 1.0.29

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.
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDownloadCounts = getDownloadCounts;
4
+ exports.incrementDownloadCount = incrementDownloadCount;
5
+ const storage_1 = require("@google-cloud/storage");
6
+ const logger_1 = require("../logger");
7
+ const config_1 = require("../config");
8
+ const DEFAULT_COUNTS = { macos: 0, windows: 0 };
9
+ // Initialised once at module load — uses Application Default Credentials when
10
+ // running on Cloud Run (or any GCP environment), and falls back to ADC from
11
+ // the local environment during development.
12
+ const storage = new storage_1.Storage();
13
+ function getGcsConfig() {
14
+ const bucketName = config_1.config.gcsBucketName;
15
+ const objectPath = config_1.config.gcsDownloadCountObject;
16
+ if (!bucketName || !objectPath)
17
+ return null;
18
+ return { bucketName, objectPath };
19
+ }
20
+ async function getDownloadCounts() {
21
+ const gcs = getGcsConfig();
22
+ if (!gcs)
23
+ return { ...DEFAULT_COUNTS };
24
+ return readCounts(gcs.bucketName, gcs.objectPath);
25
+ }
26
+ async function readCounts(bucketName, objectPath) {
27
+ const file = storage.bucket(bucketName).file(objectPath);
28
+ const [exists] = await file.exists();
29
+ if (!exists) {
30
+ return { ...DEFAULT_COUNTS };
31
+ }
32
+ const [contents] = await file.download();
33
+ const parsed = JSON.parse(contents.toString('utf8'));
34
+ return {
35
+ macos: typeof parsed.macos === 'number' ? parsed.macos : 0,
36
+ windows: typeof parsed.windows === 'number' ? parsed.windows : 0,
37
+ };
38
+ }
39
+ async function writeCounts(bucketName, objectPath, counts) {
40
+ const file = storage.bucket(bucketName).file(objectPath);
41
+ await file.save(JSON.stringify(counts), {
42
+ contentType: 'application/json',
43
+ resumable: false,
44
+ });
45
+ }
46
+ async function incrementDownloadCount(platform) {
47
+ const gcs = getGcsConfig();
48
+ if (!gcs)
49
+ return;
50
+ try {
51
+ const counts = await readCounts(gcs.bucketName, gcs.objectPath);
52
+ counts[platform] += 1;
53
+ await writeCounts(gcs.bucketName, gcs.objectPath, counts);
54
+ logger_1.logger.info(`Download count incremented for ${platform}.`, { counts });
55
+ }
56
+ catch (err) {
57
+ logger_1.logger.error(`Failed to increment download count for ${platform}.`, { error: err });
58
+ }
59
+ }
@@ -93,4 +93,15 @@ exports.config = {
93
93
  searxngUrl: getEnv('SEARXNG_URL', false),
94
94
  terminalPlatform: getEnv('TERMINAL_PLATFORM', false),
95
95
  blockSaas: getBooleanEnv('BLOCK_SAAS', false),
96
+ // User-configured CDP debug port (set by `omnikey grant-browser-access`)
97
+ browserDebugPort: (() => {
98
+ const raw = getEnv('BROWSER_DEBUG_PORT', false);
99
+ if (!raw)
100
+ return undefined;
101
+ const n = parseInt(raw, 10);
102
+ return Number.isNaN(n) ? undefined : n;
103
+ })(),
104
+ // GCS download-count tracking (both must be set to enable counting)
105
+ gcsBucketName: getEnv('GCS_BUCKET_NAME', false),
106
+ gcsDownloadCountObject: getEnv('GCS_DOWNLOAD_COUNT_OBJECT', false),
96
107
  };
@@ -13,10 +13,14 @@ const featureRoutes_1 = require("./featureRoutes");
13
13
  const db_1 = require("./db");
14
14
  const logger_1 = require("./logger");
15
15
  const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
16
+ const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
17
+ const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
16
18
  const config_1 = require("./config");
17
19
  const agentServer_1 = require("./agent/agentServer");
18
- // Importing AgentSession ensures the model is registered with Sequelize before initDatabase().
20
+ // Importing AgentSession and ScheduledJob ensures the models are registered with Sequelize before initDatabase().
19
21
  require("./models/agentSession");
22
+ require("./models/scheduledJob");
23
+ const bucket_adapter_1 = require("./bucket-adapter");
20
24
  const app = (0, express_1.default)();
21
25
  const PORT = Number(config_1.config.port);
22
26
  app.set('trust proxy', 1);
@@ -27,6 +31,7 @@ app.use(express_1.default.static(path_1.default.join(process.cwd(), 'public')));
27
31
  app.use('/api/subscription', (0, subscriptionRoutes_1.createSubscriptionRouter)(logger_1.logger));
28
32
  app.use('/api/feature', (0, featureRoutes_1.createFeatureRouter)());
29
33
  app.use('/api/instructions', (0, taskInstructionRoutes_1.taskInstructionRouter)());
34
+ app.use('/api/scheduled-jobs', (0, scheduledJobRoutes_1.scheduledJobRouter)());
30
35
  app.use('/api/agent', (0, agentServer_1.createAgentRouter)());
31
36
  app.get('/macos/download', (_req, res) => {
32
37
  const dmgPath = path_1.default.join(process.cwd(), 'macOS', 'OmniKeyAI.dmg');
@@ -39,6 +44,7 @@ app.get('/macos/download', (_req, res) => {
39
44
  'Content-Disposition': 'attachment; filename="OmniKeyAI.dmg"',
40
45
  'Content-Encoding': 'gzip',
41
46
  });
47
+ (0, bucket_adapter_1.incrementDownloadCount)('macos').catch(() => { });
42
48
  const fileStream = fs_1.default.createReadStream(dmgPath);
43
49
  const gzip = zlib_1.default.createGzip();
44
50
  fileStream.on('error', (err) => {
@@ -68,8 +74,8 @@ app.get('/macos/appcast', (req, res) => {
68
74
  const appcastUrl = `${baseUrl}/macos/appcast`;
69
75
  // These should match the values embedded into the macOS app
70
76
  // Info.plist in macOS/build_release_dmg.sh.
71
- const bundleVersion = '21';
72
- const shortVersion = '1.0.20';
77
+ const bundleVersion = '23';
78
+ const shortVersion = '1.0.22';
73
79
  const xml = `<?xml version="1.0" encoding="utf-8"?>
74
80
  <rss version="2.0"
75
81
  xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
@@ -97,7 +103,7 @@ app.get('/macos/appcast', (req, res) => {
97
103
  // ── Windows distribution endpoints ───────────────────────────────────────────
98
104
  // These should match the values in windows/OmniKey.Windows.csproj
99
105
  // <Version> and windows/build_release_zip.ps1 $APP_VERSION.
100
- const WIN_VERSION = '1.7';
106
+ const WIN_VERSION = '1.9';
101
107
  const WIN_ZIP_FILENAME = 'OmniKeyAI-windows-win-x64.zip';
102
108
  const WIN_ZIP_PATH = path_1.default.join(process.cwd(), 'windows', WIN_ZIP_FILENAME);
103
109
  // Serves the pre-built ZIP produced by windows/build_release_zip.ps1.
@@ -112,6 +118,7 @@ app.get('/windows/download', (_req, res) => {
112
118
  'Content-Disposition': `attachment; filename="${WIN_ZIP_FILENAME}"`,
113
119
  'Content-Encoding': 'gzip',
114
120
  });
121
+ (0, bucket_adapter_1.incrementDownloadCount)('windows').catch(() => { });
115
122
  const fileStream = fs_1.default.createReadStream(WIN_ZIP_PATH);
116
123
  const gzip = zlib_1.default.createGzip();
117
124
  fileStream.on('error', (err) => {
@@ -138,9 +145,19 @@ app.get('/windows/update', (req, res) => {
138
145
  version: WIN_VERSION,
139
146
  downloadUrl: `${baseUrl}/windows/download`,
140
147
  fileSize,
141
- releaseNotes: '',
148
+ releaseNotes: `What's new in ${WIN_VERSION}\n\n• New cron job automation (Scheduled Jobs) — create recurring jobs with cron-style schedules or one-time jobs to run prompts automatically in the background.\n• Scheduled Jobs controls — add jobs, activate/deactivate them, run now on demand, refresh status, and view last-run history in the app.\n• OmniAgent session management — choose to start a new session or resume an existing one each time you run @omniAgent. Save a default to skip the picker automatically on future runs.\n• History button in the OmniAgent window — change your default session at any time without re-running the agent.\n• OmniAgent Session tray menu item — open session settings directly from the system tray.\n• Left-clicking the tray icon now opens the menu (previously right-click only).\n• Manual updated with detailed OmniAgent, session management, web search provider, and LLM provider documentation.`,
142
149
  });
143
150
  });
151
+ app.get('/downloads/stats', async (_req, res) => {
152
+ try {
153
+ const counts = await (0, bucket_adapter_1.getDownloadCounts)();
154
+ res.json(counts);
155
+ }
156
+ catch (err) {
157
+ logger_1.logger.error('Failed to retrieve download stats.', { error: err });
158
+ res.status(500).json({ error: 'Unable to retrieve download stats.' });
159
+ }
160
+ });
144
161
  app.get('/health', (_req, res) => {
145
162
  res.json({ status: 'ok' });
146
163
  });
@@ -154,6 +171,7 @@ async function start() {
154
171
  server = app.listen(PORT, () => {
155
172
  logger_1.logger.info(`Enhancer API listening on http://localhost:${PORT}`, {
156
173
  isSelfHosted: config_1.config.isSelfHosted,
174
+ aiProvider: config_1.config.aiProvider,
157
175
  });
158
176
  });
159
177
  // Attach the WebSocket-based agent server to the existing HTTP
@@ -162,6 +180,9 @@ async function start() {
162
180
  if (server) {
163
181
  (0, agentServer_1.attachAgentWebSocketServer)(server);
164
182
  }
183
+ if (config_1.config.isSelfHosted) {
184
+ (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
185
+ }
165
186
  }
166
187
  catch (err) {
167
188
  logger_1.logger.error('Failed to start server due to DB error.', { error: err });
@@ -0,0 +1,97 @@
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.ScheduledJob = void 0;
7
+ const sequelize_1 = require("sequelize");
8
+ const cuid_1 = __importDefault(require("cuid"));
9
+ const db_1 = require("../db");
10
+ const subscription_1 = require("./subscription");
11
+ class ScheduledJob extends sequelize_1.Model {
12
+ }
13
+ exports.ScheduledJob = ScheduledJob;
14
+ ScheduledJob.init({
15
+ id: {
16
+ type: sequelize_1.DataTypes.STRING,
17
+ primaryKey: true,
18
+ allowNull: false,
19
+ defaultValue: () => (0, cuid_1.default)(),
20
+ },
21
+ subscriptionId: {
22
+ type: sequelize_1.DataTypes.STRING,
23
+ allowNull: false,
24
+ field: 'subscription_id',
25
+ references: {
26
+ model: subscription_1.Subscription,
27
+ key: 'id',
28
+ },
29
+ onDelete: 'CASCADE',
30
+ onUpdate: 'CASCADE',
31
+ },
32
+ label: {
33
+ type: sequelize_1.DataTypes.STRING(200),
34
+ allowNull: false,
35
+ },
36
+ prompt: {
37
+ type: sequelize_1.DataTypes.TEXT,
38
+ allowNull: false,
39
+ },
40
+ cronExpression: {
41
+ type: sequelize_1.DataTypes.STRING,
42
+ allowNull: true,
43
+ field: 'cron_expression',
44
+ },
45
+ runAt: {
46
+ type: sequelize_1.DataTypes.DATE,
47
+ allowNull: true,
48
+ field: 'run_at',
49
+ },
50
+ isActive: {
51
+ type: sequelize_1.DataTypes.BOOLEAN,
52
+ allowNull: false,
53
+ defaultValue: true,
54
+ field: 'is_active',
55
+ },
56
+ lastRunAt: {
57
+ type: sequelize_1.DataTypes.DATE,
58
+ allowNull: true,
59
+ field: 'last_run_at',
60
+ },
61
+ nextRunAt: {
62
+ type: sequelize_1.DataTypes.DATE,
63
+ allowNull: true,
64
+ field: 'next_run_at',
65
+ },
66
+ sessionId: {
67
+ type: sequelize_1.DataTypes.STRING,
68
+ allowNull: true,
69
+ field: 'session_id',
70
+ },
71
+ lastRunSessionId: {
72
+ type: sequelize_1.DataTypes.STRING,
73
+ allowNull: true,
74
+ field: 'last_run_session_id',
75
+ },
76
+ platform: {
77
+ type: sequelize_1.DataTypes.STRING,
78
+ allowNull: true,
79
+ },
80
+ }, {
81
+ sequelize: db_1.sequelize,
82
+ tableName: 'scheduled_jobs',
83
+ modelName: 'ScheduledJob',
84
+ indexes: [
85
+ {
86
+ fields: ['subscription_id', 'next_run_at'],
87
+ },
88
+ ],
89
+ });
90
+ subscription_1.Subscription.hasMany(ScheduledJob, {
91
+ foreignKey: 'subscriptionId',
92
+ as: 'scheduledJobs',
93
+ });
94
+ ScheduledJob.belongsTo(subscription_1.Subscription, {
95
+ foreignKey: 'subscriptionId',
96
+ as: 'subscription',
97
+ });
@@ -0,0 +1,199 @@
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.computeNextRunAt = computeNextRunAt;
7
+ exports.startScheduledJobExecutor = startScheduledJobExecutor;
8
+ exports.executeJob = executeJob;
9
+ const child_process_1 = require("child_process");
10
+ const promises_1 = require("fs/promises");
11
+ const os_1 = require("os");
12
+ const path_1 = __importDefault(require("path"));
13
+ const util_1 = require("util");
14
+ const cuid_1 = __importDefault(require("cuid"));
15
+ const sequelize_1 = require("sequelize");
16
+ const cron_parser_1 = require("cron-parser");
17
+ const scheduledJob_1 = require("./models/scheduledJob");
18
+ const subscription_1 = require("./models/subscription");
19
+ const logger_1 = require("./logger");
20
+ const agentServer_1 = require("./agent/agentServer");
21
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
22
+ const SHELL_SCRIPT_RE = /<shell_script>([\s\S]*?)<\/shell_script>/;
23
+ const FINAL_ANSWER_RE = /<final_answer>/;
24
+ // Maximum time a single job may run before it is forcibly cancelled.
25
+ const JOB_TIMEOUT_MS = 10 * 60 * 1000;
26
+ // Cron jobs get more turns than interactive sessions so multi-step tasks
27
+ // (web research → shell commands → final answer) can complete unattended.
28
+ const MAX_CRON_TURNS = 20;
29
+ function computeNextRunAt(cronExpression, runAt) {
30
+ if (cronExpression) {
31
+ try {
32
+ const interval = (0, cron_parser_1.parseExpression)(cronExpression, { currentDate: new Date() });
33
+ return interval.next().toDate();
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (runAt && runAt > new Date()) {
40
+ return runAt;
41
+ }
42
+ return null;
43
+ }
44
+ function startScheduledJobExecutor() {
45
+ logger_1.logger.info('Scheduled job executor started.');
46
+ void executeDueJobs();
47
+ setInterval(() => void executeDueJobs(), 60000);
48
+ }
49
+ async function executeDueJobs() {
50
+ try {
51
+ const now = new Date();
52
+ const dueJobs = await scheduledJob_1.ScheduledJob.findAll({
53
+ where: {
54
+ nextRunAt: { [sequelize_1.Op.lte]: now },
55
+ isActive: true,
56
+ },
57
+ });
58
+ if (dueJobs.length > 0) {
59
+ logger_1.logger.info(`Executing ${dueJobs.length} due scheduled job(s).`);
60
+ }
61
+ for (const job of dueJobs) {
62
+ void executeJob(job).catch((err) => {
63
+ logger_1.logger.error('Scheduled job execution failed.', { jobId: job.id, error: err });
64
+ });
65
+ }
66
+ }
67
+ catch (err) {
68
+ logger_1.logger.error('Error polling for due scheduled jobs.', { error: err });
69
+ }
70
+ }
71
+ // Runs the script in the user's login shell so PATH and profile env-vars are
72
+ // present — identical to how the desktop apps open a terminal. Writing to a
73
+ // temp file avoids quoting/escaping issues with multi-line scripts.
74
+ async function runScript(script) {
75
+ const isWin = process.platform === 'win32';
76
+ const userHome = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd();
77
+ const userShell = isWin ? (process.env.COMSPEC ?? 'cmd.exe') : (process.env.SHELL ?? '/bin/zsh');
78
+ const ext = isWin ? '.bat' : '.sh';
79
+ const tmpFile = path_1.default.join((0, os_1.tmpdir)(), `cron_${(0, cuid_1.default)()}${ext}`);
80
+ try {
81
+ if (isWin) {
82
+ await (0, promises_1.writeFile)(tmpFile, `@echo off\r\n${script}`, 'utf8');
83
+ }
84
+ else {
85
+ await (0, promises_1.writeFile)(tmpFile, script, { encoding: 'utf8', mode: 0o700 });
86
+ }
87
+ // -l = login shell → sources ~/.zprofile / ~/.bash_profile etc.
88
+ const command = isWin ? `"${tmpFile}"` : `"${userShell}" -l "${tmpFile}"`;
89
+ const { stdout, stderr } = await execAsync(command, {
90
+ timeout: 60000,
91
+ cwd: userHome,
92
+ env: process.env,
93
+ });
94
+ const combined = [stdout, stderr ? `STDERR:\n${stderr}` : ''].filter(Boolean).join('\n').trim();
95
+ return { output: combined || '(no output)', isError: false };
96
+ }
97
+ catch (err) {
98
+ const combined = [err.stdout ?? '', err.stderr ?? ''].filter(Boolean).join('\n').trim();
99
+ return { output: combined || err.message || 'Command failed', isError: true };
100
+ }
101
+ finally {
102
+ (0, promises_1.unlink)(tmpFile).catch(() => { });
103
+ }
104
+ }
105
+ function runCronJob(job, subscription, sessionId) {
106
+ return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ const settle = (err) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ clearTimeout(timeoutHandle);
113
+ err ? reject(err) : resolve();
114
+ };
115
+ const timeoutHandle = setTimeout(() => settle(new Error(`Cron job ${job.id} timed out after ${JOB_TIMEOUT_MS / 60000} minutes`)), JOB_TIMEOUT_MS);
116
+ const send = (msg) => {
117
+ if (settled)
118
+ return;
119
+ void (async () => {
120
+ const content = msg.content ?? '';
121
+ if (msg.is_error) {
122
+ logger_1.logger.error('Cron job: agent returned error.', {
123
+ jobId: job.id,
124
+ content: content.slice(0, 300),
125
+ });
126
+ settle(new Error(`Agent error: ${content.slice(0, 200)}`));
127
+ return;
128
+ }
129
+ const scriptMatch = SHELL_SCRIPT_RE.exec(content);
130
+ if (scriptMatch) {
131
+ const script = scriptMatch[1].trim();
132
+ logger_1.logger.info('Cron job: executing shell script.', { jobId: job.id });
133
+ const { output, isError } = await runScript(script);
134
+ logger_1.logger.info('Cron job: shell script finished.', {
135
+ jobId: job.id,
136
+ isError,
137
+ outputLength: output.length,
138
+ });
139
+ if (settled)
140
+ return;
141
+ (0, agentServer_1.runAgentTurn)(sessionId, subscription, {
142
+ session_id: sessionId,
143
+ sender: 'user',
144
+ content: output,
145
+ is_terminal_output: true,
146
+ is_error: isError,
147
+ }, send, logger_1.logger, { maxTurns: MAX_CRON_TURNS }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
148
+ return;
149
+ }
150
+ if (FINAL_ANSWER_RE.test(content)) {
151
+ logger_1.logger.info('Cron job: received final answer.', { jobId: job.id });
152
+ settle();
153
+ }
154
+ })();
155
+ };
156
+ (0, agentServer_1.runAgentTurn)(sessionId, subscription, {
157
+ session_id: sessionId,
158
+ sender: 'user',
159
+ content: job.prompt,
160
+ platform: job.platform ?? undefined,
161
+ }, send, logger_1.logger, { maxTurns: MAX_CRON_TURNS }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
162
+ });
163
+ }
164
+ async function executeJob(job) {
165
+ logger_1.logger.info('Executing scheduled job.', { jobId: job.id, label: job.label });
166
+ const subscription = await subscription_1.Subscription.findByPk(job.subscriptionId);
167
+ if (!subscription) {
168
+ logger_1.logger.error('Subscription not found for scheduled job; skipping.', {
169
+ jobId: job.id,
170
+ subscriptionId: job.subscriptionId,
171
+ });
172
+ return;
173
+ }
174
+ const sessionId = (0, cuid_1.default)();
175
+ try {
176
+ await runCronJob(job, subscription, sessionId);
177
+ logger_1.logger.info('Scheduled job completed.', { jobId: job.id, label: job.label });
178
+ }
179
+ catch (err) {
180
+ logger_1.logger.error('Scheduled job failed.', { jobId: job.id, label: job.label, error: err });
181
+ // Fall through — always update lastRunAt so the next poll does not re-run immediately.
182
+ }
183
+ const now = new Date();
184
+ if (job.cronExpression) {
185
+ await job.update({
186
+ lastRunAt: now,
187
+ nextRunAt: computeNextRunAt(job.cronExpression, null),
188
+ lastRunSessionId: sessionId,
189
+ });
190
+ }
191
+ else {
192
+ await job.update({
193
+ lastRunAt: now,
194
+ isActive: false,
195
+ nextRunAt: null,
196
+ lastRunSessionId: sessionId,
197
+ });
198
+ }
199
+ }
@@ -0,0 +1,186 @@
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.scheduledJobRouter = scheduledJobRouter;
7
+ const express_1 = __importDefault(require("express"));
8
+ const zod_1 = __importDefault(require("zod"));
9
+ const authMiddleware_1 = require("./authMiddleware");
10
+ const scheduledJob_1 = require("./models/scheduledJob");
11
+ const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
12
+ const CRON_REGEX = /^(\S+\s){4}\S+$/;
13
+ const jobSchema = zod_1.default.object({
14
+ label: zod_1.default.string().min(1).max(200),
15
+ prompt: zod_1.default.string().min(1),
16
+ cronExpression: zod_1.default.string().regex(CRON_REGEX, 'Invalid cron expression (must be 5 fields)').optional(),
17
+ runAt: zod_1.default.string().optional(),
18
+ isActive: zod_1.default.boolean().optional(),
19
+ sessionId: zod_1.default.string().nullable().optional(),
20
+ platform: zod_1.default.string().optional(),
21
+ });
22
+ function formatJob(job) {
23
+ return {
24
+ id: job.id,
25
+ label: job.label,
26
+ prompt: job.prompt,
27
+ cronExpression: job.cronExpression,
28
+ runAt: job.runAt,
29
+ isActive: job.isActive,
30
+ lastRunAt: job.lastRunAt,
31
+ nextRunAt: job.nextRunAt,
32
+ sessionId: job.sessionId,
33
+ lastRunSessionId: job.lastRunSessionId,
34
+ platform: job.platform,
35
+ createdAt: job.createdAt,
36
+ updatedAt: job.updatedAt,
37
+ };
38
+ }
39
+ function scheduledJobRouter() {
40
+ const router = express_1.default.Router();
41
+ router.get('/', authMiddleware_1.authMiddleware, async (req, res) => {
42
+ const { logger, subscription } = res.locals;
43
+ try {
44
+ const jobs = await scheduledJob_1.ScheduledJob.findAll({
45
+ where: { subscriptionId: subscription.id },
46
+ order: [['next_run_at', 'ASC NULLS LAST']],
47
+ });
48
+ res.json({ jobs: jobs.map(formatJob) });
49
+ }
50
+ catch (err) {
51
+ logger.error('Error retrieving scheduled jobs.', { error: err });
52
+ res.status(500).json({ error: 'Failed to retrieve scheduled jobs.' });
53
+ }
54
+ });
55
+ router.post('/', authMiddleware_1.authMiddleware, async (req, res) => {
56
+ const { logger, subscription } = res.locals;
57
+ try {
58
+ const parsed = jobSchema.parse(req.body);
59
+ const hasCron = !!parsed.cronExpression;
60
+ const hasRunAt = !!parsed.runAt;
61
+ if (!hasCron && !hasRunAt) {
62
+ return res.status(400).json({ error: 'Either cronExpression or runAt is required.' });
63
+ }
64
+ if (hasCron && hasRunAt) {
65
+ return res.status(400).json({ error: 'Provide either cronExpression or runAt, not both.' });
66
+ }
67
+ let runAt = null;
68
+ if (hasRunAt) {
69
+ runAt = new Date(parsed.runAt);
70
+ if (isNaN(runAt.getTime())) {
71
+ return res.status(400).json({ error: 'Invalid runAt date.' });
72
+ }
73
+ if (runAt <= new Date()) {
74
+ return res.status(400).json({ error: 'runAt must be in the future.' });
75
+ }
76
+ }
77
+ const nextRunAt = (0, scheduledJobExecutor_1.computeNextRunAt)(parsed.cronExpression ?? null, runAt);
78
+ const job = await scheduledJob_1.ScheduledJob.create({
79
+ subscriptionId: subscription.id,
80
+ label: parsed.label,
81
+ prompt: parsed.prompt,
82
+ cronExpression: parsed.cronExpression ?? null,
83
+ runAt,
84
+ isActive: parsed.isActive ?? true,
85
+ nextRunAt,
86
+ sessionId: parsed.sessionId ?? null,
87
+ platform: parsed.platform ?? null,
88
+ });
89
+ res.status(201).json(formatJob(job));
90
+ }
91
+ catch (err) {
92
+ logger.error('Error creating scheduled job.', { error: err });
93
+ if (err instanceof zod_1.default.ZodError) {
94
+ return res.status(400).json({ error: 'Invalid job data.' });
95
+ }
96
+ res.status(500).json({ error: 'Failed to create scheduled job.' });
97
+ }
98
+ });
99
+ router.put('/:id', authMiddleware_1.authMiddleware, async (req, res) => {
100
+ const { logger, subscription } = res.locals;
101
+ const { id } = req.params;
102
+ try {
103
+ const parsed = jobSchema.partial().parse(req.body);
104
+ const job = await scheduledJob_1.ScheduledJob.findOne({
105
+ where: { id, subscriptionId: subscription.id },
106
+ });
107
+ if (!job) {
108
+ return res.status(404).json({ error: 'Scheduled job not found.' });
109
+ }
110
+ const cronExpression = parsed.cronExpression !== undefined ? parsed.cronExpression ?? null : job.cronExpression;
111
+ let runAt = job.runAt;
112
+ if (parsed.runAt !== undefined) {
113
+ if (parsed.runAt) {
114
+ runAt = new Date(parsed.runAt);
115
+ if (isNaN(runAt.getTime())) {
116
+ return res.status(400).json({ error: 'Invalid runAt date.' });
117
+ }
118
+ if (runAt <= new Date()) {
119
+ return res.status(400).json({ error: 'runAt must be in the future.' });
120
+ }
121
+ }
122
+ else {
123
+ runAt = null;
124
+ }
125
+ }
126
+ const nextRunAt = (0, scheduledJobExecutor_1.computeNextRunAt)(cronExpression, runAt);
127
+ await job.update({
128
+ label: parsed.label ?? job.label,
129
+ prompt: parsed.prompt ?? job.prompt,
130
+ cronExpression,
131
+ runAt,
132
+ isActive: parsed.isActive ?? job.isActive,
133
+ nextRunAt,
134
+ sessionId: parsed.sessionId !== undefined ? (parsed.sessionId ?? null) : job.sessionId,
135
+ platform: parsed.platform ?? job.platform,
136
+ });
137
+ res.json(formatJob(job));
138
+ }
139
+ catch (err) {
140
+ logger.error('Error updating scheduled job.', { error: err });
141
+ if (err instanceof zod_1.default.ZodError) {
142
+ return res.status(400).json({ error: 'Invalid job data.' });
143
+ }
144
+ res.status(500).json({ error: 'Failed to update scheduled job.' });
145
+ }
146
+ });
147
+ router.delete('/:id', authMiddleware_1.authMiddleware, async (req, res) => {
148
+ const { logger, subscription } = res.locals;
149
+ const { id } = req.params;
150
+ try {
151
+ const job = await scheduledJob_1.ScheduledJob.findOne({
152
+ where: { id, subscriptionId: subscription.id },
153
+ });
154
+ if (!job) {
155
+ return res.status(404).json({ error: 'Scheduled job not found.' });
156
+ }
157
+ await job.destroy();
158
+ res.status(204).send();
159
+ }
160
+ catch (err) {
161
+ logger.error('Error deleting scheduled job.', { error: err });
162
+ res.status(500).json({ error: 'Failed to delete scheduled job.' });
163
+ }
164
+ });
165
+ router.post('/:id/run-now', authMiddleware_1.authMiddleware, async (req, res) => {
166
+ const { logger, subscription } = res.locals;
167
+ const { id } = req.params;
168
+ try {
169
+ const job = await scheduledJob_1.ScheduledJob.findOne({
170
+ where: { id, subscriptionId: subscription.id },
171
+ });
172
+ if (!job) {
173
+ return res.status(404).json({ error: 'Scheduled job not found.' });
174
+ }
175
+ void (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
176
+ logger.error('run-now execution failed.', { jobId: job.id, error: err });
177
+ });
178
+ res.json(formatJob(job));
179
+ }
180
+ catch (err) {
181
+ logger.error('Error triggering scheduled job.', { error: err });
182
+ res.status(500).json({ error: 'Failed to trigger scheduled job.' });
183
+ }
184
+ });
185
+ return router;
186
+ }