neoagent 2.2.0 → 2.2.1-beta.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.
@@ -0,0 +1,101 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { requireAuth } = require('../middleware/auth');
4
+ const { sanitizeError } = require('../utils/security');
5
+ const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
6
+
7
+ router.use(requireAuth);
8
+
9
+ function widgetService(req) {
10
+ return req.app?.locals?.widgetService;
11
+ }
12
+
13
+ router.get('/snapshots', (req, res) => {
14
+ try {
15
+ const service = widgetService(req);
16
+ if (!service) {
17
+ return res.status(500).json({ error: 'Widget service unavailable.' });
18
+ }
19
+ const agentId = req.query?.all === 'true'
20
+ ? null
21
+ : resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
22
+ res.json(service.listLatestSnapshots(req.session.userId, { agentId }));
23
+ } catch (err) {
24
+ res.status(400).json({ error: sanitizeError(err) });
25
+ }
26
+ });
27
+
28
+ router.get('/', (req, res) => {
29
+ try {
30
+ const service = widgetService(req);
31
+ if (!service) {
32
+ return res.status(500).json({ error: 'Widget service unavailable.' });
33
+ }
34
+ const agentId = req.query?.all === 'true'
35
+ ? null
36
+ : resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
37
+ res.json(service.listWidgets(req.session.userId, { agentId }));
38
+ } catch (err) {
39
+ res.status(400).json({ error: sanitizeError(err) });
40
+ }
41
+ });
42
+
43
+ router.post('/', (req, res) => {
44
+ try {
45
+ const service = widgetService(req);
46
+ if (!service) {
47
+ return res.status(500).json({ error: 'Widget service unavailable.' });
48
+ }
49
+ const widget = service.createWidget(req.session.userId, req.body || {});
50
+ res.status(201).json(widget);
51
+ } catch (err) {
52
+ res.status(400).json({ error: sanitizeError(err) });
53
+ }
54
+ });
55
+
56
+ router.put('/:id', (req, res) => {
57
+ try {
58
+ const service = widgetService(req);
59
+ if (!service) {
60
+ return res.status(500).json({ error: 'Widget service unavailable.' });
61
+ }
62
+ const widget = service.updateWidget(req.session.userId, req.params.id, req.body || {});
63
+ res.json(widget);
64
+ } catch (err) {
65
+ res.status(400).json({ error: sanitizeError(err) });
66
+ }
67
+ });
68
+
69
+ router.delete('/:id', (req, res) => {
70
+ try {
71
+ const service = widgetService(req);
72
+ if (!service) {
73
+ return res.status(500).json({ error: 'Widget service unavailable.' });
74
+ }
75
+ res.json(service.deleteWidget(req.session.userId, req.params.id));
76
+ } catch (err) {
77
+ res.status(400).json({ error: sanitizeError(err) });
78
+ }
79
+ });
80
+
81
+ router.post('/:id/refresh', (req, res) => {
82
+ try {
83
+ const service = widgetService(req);
84
+ const scheduler = req.app?.locals?.scheduler;
85
+ if (!service || !scheduler) {
86
+ return res.status(500).json({ error: 'Widget refresh unavailable.' });
87
+ }
88
+ const widget = service.getWidget(req.session.userId, req.params.id);
89
+ if (!widget) {
90
+ return res.status(404).json({ error: 'Widget not found.' });
91
+ }
92
+ if (!widget.scheduledTaskId) {
93
+ return res.status(400).json({ error: 'Widget is missing its refresh task.' });
94
+ }
95
+ res.json(scheduler.runTaskNow(widget.scheduledTaskId, req.session.userId));
96
+ } catch (err) {
97
+ res.status(400).json({ error: sanitizeError(err) });
98
+ }
99
+ });
100
+
101
+ module.exports = router;
@@ -474,6 +474,10 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
474
474
  'update_scheduled_task',
475
475
  'delete_scheduled_task',
476
476
  'schedule_run',
477
+ 'create_ai_widget',
478
+ 'update_ai_widget',
479
+ 'delete_ai_widget',
480
+ 'save_widget_snapshot',
477
481
  'mcp_add_server',
478
482
  'mcp_remove_server',
479
483
  'spawn_subagent',
@@ -498,7 +502,7 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
498
502
  ? 'command'
499
503
  : name.includes('skill')
500
504
  ? 'skills'
501
- : name.includes('scheduled_task') || name === 'schedule_run'
505
+ : name.includes('scheduled_task') || name === 'schedule_run' || name.includes('widget')
502
506
  ? 'scheduler'
503
507
  : name === 'send_message' || name === 'make_call'
504
508
  ? 'messaging'
@@ -1966,6 +1970,7 @@ class AgentEngine {
1966
1970
  source: options.source || null,
1967
1971
  chatId: options.chatId || null,
1968
1972
  taskId: options.taskId || null,
1973
+ widgetId: options.widgetId || null,
1969
1974
  deliveryState: options.deliveryState || null,
1970
1975
  allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
1971
1976
  allowExternalSideEffects: options.allowExternalSideEffects === true,
@@ -2801,7 +2806,7 @@ class AgentEngine {
2801
2806
  if (toolName === 'send_message') return 'messaging';
2802
2807
  if (toolName === 'make_call') return 'messaging';
2803
2808
  if (toolName.startsWith('mcp_') || toolName.includes('mcp')) return 'mcp';
2804
- if (toolName.includes('scheduled_task') || toolName === 'schedule_run') return 'scheduler';
2809
+ if (toolName.includes('scheduled_task') || toolName === 'schedule_run' || toolName.includes('widget')) return 'scheduler';
2805
2810
  if (toolName.includes('subagent')) return 'subagent';
2806
2811
  if (toolName === 'think') return 'thinking';
2807
2812
  return 'tool';
@@ -221,9 +221,34 @@ function compactToolResult(toolName, toolArgs = {}, toolResult, options = {}) {
221
221
  case 'schedule_run':
222
222
  case 'delete_scheduled_task':
223
223
  case 'update_scheduled_task':
224
+ case 'create_ai_widget':
225
+ case 'update_ai_widget':
226
+ case 'delete_ai_widget':
227
+ case 'save_widget_snapshot':
224
228
  envelope = buildSimpleStatusEnvelope(toolName, toolResult, softLimit);
225
229
  break;
226
230
 
231
+ case 'list_ai_widgets':
232
+ envelope = trimObject({
233
+ tool: toolName,
234
+ status: toolResult?.success === false || toolResult?.error ? 'error' : 'ok',
235
+ message: clampText(toolResult?.message || toolResult?.error || '', Math.floor(softLimit * 0.3)),
236
+ count: typeof toolResult?.count === 'number'
237
+ ? toolResult.count
238
+ : (Array.isArray(toolResult?.widgets) ? toolResult.widgets.length : undefined),
239
+ widgets: Array.isArray(toolResult?.widgets)
240
+ ? toolResult.widgets.slice(0, 6).map((widget) => trimObject({
241
+ id: widget?.id,
242
+ name: widget?.name,
243
+ template: widget?.template,
244
+ layoutVariant: widget?.layoutVariant,
245
+ refreshCron: widget?.refreshCron,
246
+ enabled: widget?.enabled,
247
+ }))
248
+ : undefined,
249
+ });
250
+ break;
251
+
227
252
  case 'spawn_subagent':
228
253
  envelope = trimObject({
229
254
  tool: toolName,
@@ -936,6 +936,60 @@ function getAvailableTools(app, options = {}) {
936
936
  required: ['task_id']
937
937
  }
938
938
  },
939
+ {
940
+ name: 'create_ai_widget',
941
+ description: 'Create an AI widget with a fixed template, approved layout variant, refresh cadence, and definition prompt. Cadence must be at least 1 hour.',
942
+ parameters: {
943
+ type: 'object',
944
+ properties: {
945
+ name: { type: 'string', description: 'Short widget name.' },
946
+ template: { type: 'string', enum: ['stat', 'summary', 'list'], description: 'Widget template family.' },
947
+ layout_variant: { type: 'string', description: 'Approved layout variant for the chosen template.' },
948
+ refresh_cron: { type: 'string', description: '5-field cron cadence. Never set faster than hourly.' },
949
+ prompt: { type: 'string', description: 'Self-contained definition of what the widget should track and how it should summarize it.' },
950
+ description: { type: 'string', description: 'Optional short operator-facing description.' },
951
+ system_hint: { type: 'string', description: 'Optional extra guidance for future refresh runs.' },
952
+ enabled: { type: 'boolean', description: 'Whether the widget should start enabled immediately.' },
953
+ run_initial_refresh: { type: 'boolean', description: 'When true, immediately run the first refresh after creation. Defaults to true.' }
954
+ },
955
+ required: ['name', 'template', 'layout_variant', 'refresh_cron', 'prompt']
956
+ }
957
+ },
958
+ {
959
+ name: 'list_ai_widgets',
960
+ description: 'List AI widgets for the current agent.',
961
+ parameters: { type: 'object', properties: {} }
962
+ },
963
+ {
964
+ name: 'update_ai_widget',
965
+ description: 'Update an existing AI widget. Use this when the user explicitly wants to change the widget definition, layout variant, cadence, or enabled state.',
966
+ parameters: {
967
+ type: 'object',
968
+ properties: {
969
+ widget_id: { type: 'string', description: 'Widget ID from list_ai_widgets.' },
970
+ name: { type: 'string', description: 'Updated widget name.' },
971
+ template: { type: 'string', enum: ['stat', 'summary', 'list'], description: 'Updated widget template.' },
972
+ layout_variant: { type: 'string', description: 'Approved layout variant for the chosen template.' },
973
+ refresh_cron: { type: 'string', description: 'Updated 5-field cron cadence. Never faster than hourly.' },
974
+ prompt: { type: 'string', description: 'Updated widget definition prompt.' },
975
+ description: { type: 'string', description: 'Optional updated operator-facing description.' },
976
+ system_hint: { type: 'string', description: 'Optional updated extra guidance for refresh runs.' },
977
+ enabled: { type: 'boolean', description: 'Enable or disable the widget.' }
978
+ },
979
+ required: ['widget_id']
980
+ }
981
+ },
982
+ {
983
+ name: 'delete_ai_widget',
984
+ description: 'Delete an AI widget by its ID.',
985
+ parameters: {
986
+ type: 'object',
987
+ properties: {
988
+ widget_id: { type: 'string', description: 'Widget ID from list_ai_widgets.' }
989
+ },
990
+ required: ['widget_id']
991
+ }
992
+ },
939
993
  {
940
994
  name: 'mcp_add_server',
941
995
  description: 'Register and optionally start a new MCP (Model Context Protocol) server connection. Use this when the user asks to connect a new MCP server or when you discover a useful one. The server will appear in the MCP Servers page and its tools will be available to you immediately if auto_start is true.',
@@ -1097,6 +1151,23 @@ function getAvailableTools(app, options = {}) {
1097
1151
  tools.push(...integrationTools);
1098
1152
  }
1099
1153
 
1154
+ if (options.triggerSource === 'scheduler' && options.widgetId) {
1155
+ tools.push({
1156
+ name: 'save_widget_snapshot',
1157
+ description: 'Save the refreshed structured snapshot for the widget that is currently being updated. Call this exactly once per widget refresh run.',
1158
+ parameters: {
1159
+ type: 'object',
1160
+ properties: {
1161
+ snapshot: {
1162
+ type: 'object',
1163
+ description: 'Structured widget snapshot payload containing title, optional subtitle/body/metric/trend/rows/chips/iconToken/accentToken/updatedAt/deepLink.'
1164
+ }
1165
+ },
1166
+ required: ['snapshot']
1167
+ }
1168
+ });
1169
+ }
1170
+
1100
1171
  let visibleTools = tools;
1101
1172
  if (options.userId != null) {
1102
1173
  try {
@@ -1137,6 +1208,7 @@ async function executeTool(toolName, args, context, engine) {
1137
1208
  app,
1138
1209
  triggerSource,
1139
1210
  taskId,
1211
+ widgetId,
1140
1212
  deliveryState = null,
1141
1213
  allowMultipleProactiveMessages = false
1142
1214
  } = context;
@@ -1176,6 +1248,7 @@ async function executeTool(toolName, args, context, engine) {
1176
1248
  const sk = () => app?.locals?.skillRunner || engine.skillRunner;
1177
1249
  const sched = () => app?.locals?.scheduler || engine.scheduler;
1178
1250
  const rec = () => app?.locals?.recordingManager || null;
1251
+ const widgets = () => app?.locals?.widgetService || null;
1179
1252
 
1180
1253
  const integrationManager = integrations();
1181
1254
  if (integrationManager) {
@@ -2224,6 +2297,115 @@ async function executeTool(toolName, args, context, engine) {
2224
2297
  }
2225
2298
  }
2226
2299
 
2300
+ case 'create_ai_widget': {
2301
+ const widgetService = widgets();
2302
+ if (!widgetService) return { error: 'Widget service not available' };
2303
+ try {
2304
+ const widget = widgetService.createWidget(userId, {
2305
+ name: args.name,
2306
+ template: args.template,
2307
+ layoutVariant: args.layout_variant,
2308
+ refreshCron: args.refresh_cron,
2309
+ prompt: args.prompt,
2310
+ description: args.description,
2311
+ definition: {
2312
+ prompt: args.prompt,
2313
+ description: args.description,
2314
+ systemHint: args.system_hint,
2315
+ },
2316
+ enabled: args.enabled !== false,
2317
+ agentId,
2318
+ });
2319
+ let initialRefresh = null;
2320
+ if (args.run_initial_refresh !== false) {
2321
+ try {
2322
+ initialRefresh = await widgetService.refreshWidget(userId, widget.id, {
2323
+ taskId: widget.scheduledTaskId || null,
2324
+ });
2325
+ } catch (refreshErr) {
2326
+ initialRefresh = { error: refreshErr.message };
2327
+ }
2328
+ }
2329
+ return {
2330
+ success: true,
2331
+ widget,
2332
+ initialRefresh,
2333
+ message: `AI widget "${widget.name}" created.`,
2334
+ };
2335
+ } catch (err) {
2336
+ return { error: err.message };
2337
+ }
2338
+ }
2339
+
2340
+ case 'list_ai_widgets': {
2341
+ const widgetService = widgets();
2342
+ if (!widgetService) return { error: 'Widget service not available' };
2343
+ const items = widgetService.listWidgets(userId, { agentId });
2344
+ return { widgets: items, count: items.length };
2345
+ }
2346
+
2347
+ case 'update_ai_widget': {
2348
+ const widgetService = widgets();
2349
+ if (!widgetService) return { error: 'Widget service not available' };
2350
+ try {
2351
+ const existing = widgetService.getWidget(userId, args.widget_id);
2352
+ if (!existing || existing.agentId !== agentId) {
2353
+ return { error: 'Widget not found for this agent.' };
2354
+ }
2355
+ const widget = widgetService.updateWidget(userId, args.widget_id, {
2356
+ name: args.name,
2357
+ template: args.template,
2358
+ layoutVariant: args.layout_variant,
2359
+ refreshCron: args.refresh_cron,
2360
+ prompt: args.prompt,
2361
+ description: args.description,
2362
+ definition: args.prompt !== undefined || args.description !== undefined || args.system_hint !== undefined
2363
+ ? {
2364
+ ...(existing.definition || {}),
2365
+ ...(args.prompt !== undefined ? { prompt: args.prompt } : {}),
2366
+ ...(args.description !== undefined ? { description: args.description } : {}),
2367
+ ...(args.system_hint !== undefined ? { systemHint: args.system_hint } : {}),
2368
+ }
2369
+ : undefined,
2370
+ enabled: args.enabled,
2371
+ agentId,
2372
+ });
2373
+ return { success: true, widget };
2374
+ } catch (err) {
2375
+ return { error: err.message };
2376
+ }
2377
+ }
2378
+
2379
+ case 'delete_ai_widget': {
2380
+ const widgetService = widgets();
2381
+ if (!widgetService) return { error: 'Widget service not available' };
2382
+ try {
2383
+ const existing = widgetService.getWidget(userId, args.widget_id);
2384
+ if (!existing || existing.agentId !== agentId) {
2385
+ return { error: 'Widget not found for this agent.' };
2386
+ }
2387
+ const deleted = widgetService.deleteWidget(userId, args.widget_id);
2388
+ return { success: true, ...deleted };
2389
+ } catch (err) {
2390
+ return { error: err.message };
2391
+ }
2392
+ }
2393
+
2394
+ case 'save_widget_snapshot': {
2395
+ const widgetService = widgets();
2396
+ if (!widgetService) return { error: 'Widget service not available' };
2397
+ if (!widgetId) return { error: 'save_widget_snapshot is only available during widget refresh runs.' };
2398
+ try {
2399
+ const snapshot = widgetService.saveSnapshot(userId, widgetId, args.snapshot, {
2400
+ sourceRunId: runId,
2401
+ status: 'ready',
2402
+ });
2403
+ return { success: true, snapshot };
2404
+ } catch (err) {
2405
+ return { error: err.message };
2406
+ }
2407
+ }
2408
+
2227
2409
  case 'mcp_add_server': {
2228
2410
  const mcpClient = mcp();
2229
2411
  if (!mcpClient) return { error: 'MCP manager not available' };
@@ -11,6 +11,7 @@ const { SkillRunner } = require('./ai/toolRunner');
11
11
  const { CommandRouter } = require('./commands/router');
12
12
  const { MessagingManager } = require('./messaging/manager');
13
13
  const { Scheduler } = require('./scheduler/cron');
14
+ const { WidgetService } = require('./widgets/service');
14
15
  const { setupWebSocket } = require('./websocket');
15
16
  const { registerMessagingAutomation } = require('./messaging/automation');
16
17
  const { RecordingManager } = require('./recordings/manager');
@@ -417,6 +418,16 @@ function createRecordingManager(app, io) {
417
418
  return recordingManager;
418
419
  }
419
420
 
421
+ function createWidgetService(app) {
422
+ const widgetService = registerLocal(
423
+ app,
424
+ 'widgetService',
425
+ new WidgetService({ app }),
426
+ );
427
+ logServiceReady('Widget service ready');
428
+ return widgetService;
429
+ }
430
+
420
431
  function restoreMessagingConnections(messagingManager) {
421
432
  void runBackgroundTask('[Messaging] Restore error:', () =>
422
433
  messagingManager.restoreConnections(),
@@ -501,6 +512,7 @@ async function startServices(app, io) {
501
512
 
502
513
  const messagingManager = createMessagingManager(app, io, agentEngine);
503
514
  const recordingManager = createRecordingManager(app, io);
515
+ createWidgetService(app);
504
516
 
505
517
  restoreMessagingConnections(messagingManager);
506
518
  restoreMcpClients(mcpClient);
@@ -608,6 +620,25 @@ async function stopServices(app) {
608
620
  );
609
621
  }
610
622
 
623
+ if (app.locals.widgetService) {
624
+ const widgetService = app.locals.widgetService;
625
+ const cleanupMethod = ['shutdown', 'close', 'stop', 'dispose'].find(
626
+ (method) => typeof widgetService[method] === 'function',
627
+ );
628
+ if (cleanupMethod) {
629
+ tasks.push(
630
+ Promise.resolve()
631
+ .then(() => widgetService[cleanupMethod]())
632
+ .then(() => {
633
+ logServiceReady(`Widget service ${cleanupMethod} completed`);
634
+ })
635
+ .catch((err) => {
636
+ console.error('[Widget] Shutdown error:', getErrorMessage(err));
637
+ }),
638
+ );
639
+ }
640
+ }
641
+
611
642
  if (app.locals.browserExtensionRegistry) {
612
643
  try {
613
644
  app.locals.browserExtensionRegistry.closeAll();
@@ -2,6 +2,7 @@ const cron = require('node-cron');
2
2
  const crypto = require('crypto');
3
3
  const db = require('../../db/database');
4
4
  const { isMainAgent, resolveAgentId } = require('../agents/manager');
5
+ const { findNextRun } = require('./cron_utils');
5
6
 
6
7
  const MAX_SCHEDULER_AUTONOMOUS_RETRIES = 1;
7
8
  const MAX_RECURRING_TASK_START_DELAY_MS = 90 * 1000;
@@ -60,11 +61,27 @@ class Scheduler {
60
61
  console.log('[Scheduler] One-time poller active (every 1 min)');
61
62
  }
62
63
 
63
- createTask(userId, { name, cronExpression, prompt, enabled = true, callTo = null, callGreeting = null, model = null, runAt = null, oneTime = false, agentId = null }) {
64
+ createTask(userId, {
65
+ name,
66
+ cronExpression,
67
+ prompt,
68
+ enabled = true,
69
+ callTo = null,
70
+ callGreeting = null,
71
+ model = null,
72
+ runAt = null,
73
+ oneTime = false,
74
+ agentId = null,
75
+ taskType = 'agent_prompt',
76
+ taskConfig = null,
77
+ }) {
64
78
  const scopedAgentId = resolveAgentId(userId, agentId);
65
79
  const notifyTarget = this._getDefaultNotifyTarget(userId, scopedAgentId);
66
80
 
67
81
  if (oneTime) {
82
+ if (taskType !== 'agent_prompt') {
83
+ throw new Error('One-time runs support only agent_prompt tasks.');
84
+ }
68
85
  if (!runAt) throw new Error('runAt is required for one-time tasks');
69
86
  const runAtDate = new Date(runAt);
70
87
  if (isNaN(runAtDate.getTime())) throw new Error(`Invalid runAt value: ${runAt}`);
@@ -88,17 +105,25 @@ class Scheduler {
88
105
  throw new Error(`Invalid cron expression: ${cronExpression}`);
89
106
  }
90
107
 
91
- const config = { prompt };
92
- if (callTo) { config.callTo = callTo; config.callGreeting = callGreeting || ''; }
93
- if (typeof model === 'string' && model.trim()) config.model = model.trim();
94
- if (notifyTarget.platform && notifyTarget.to) {
95
- config.notifyPlatform = notifyTarget.platform;
96
- config.notifyTo = notifyTarget.to;
108
+ let config;
109
+ if (taskType === 'widget_refresh') {
110
+ config = this._normalizeTaskConfig(taskConfig || {});
111
+ if (!config.widgetId) {
112
+ throw new Error('widget_refresh tasks require widgetId.');
113
+ }
114
+ } else {
115
+ config = { prompt };
116
+ if (callTo) { config.callTo = callTo; config.callGreeting = callGreeting || ''; }
117
+ if (typeof model === 'string' && model.trim()) config.model = model.trim();
118
+ if (notifyTarget.platform && notifyTarget.to) {
119
+ config.notifyPlatform = notifyTarget.platform;
120
+ config.notifyTo = notifyTarget.to;
121
+ }
97
122
  }
98
123
 
99
124
  const result = db.prepare(
100
125
  'INSERT INTO scheduled_tasks (user_id, agent_id, name, cron_expression, task_type, task_config, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)'
101
- ).run(userId, scopedAgentId, name, cronExpression, 'agent_prompt', JSON.stringify(config), enabled ? 1 : 0);
126
+ ).run(userId, scopedAgentId, name, cronExpression, taskType, JSON.stringify(config), enabled ? 1 : 0);
102
127
 
103
128
  const taskId = result.lastInsertRowid;
104
129
 
@@ -106,12 +131,15 @@ class Scheduler {
106
131
  this._scheduleTask(taskId, userId, cronExpression, config, scopedAgentId);
107
132
  }
108
133
 
109
- return { id: taskId, name, cronExpression, enabled, callTo: config.callTo || null, model: config.model || null, agentId: scopedAgentId };
134
+ return { id: taskId, name, cronExpression, enabled, callTo: config.callTo || null, model: config.model || null, agentId: scopedAgentId, taskType, widgetId: config.widgetId || null };
110
135
  }
111
136
 
112
- updateTask(taskId, userId, updates) {
137
+ updateTask(taskId, userId, updates, options = {}) {
113
138
  const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND user_id = ?').get(taskId, userId);
114
139
  if (!task) throw new Error('Task not found');
140
+ if (task.task_type === 'widget_refresh' && options.allowManaged !== true) {
141
+ throw new Error('Widget refresh tasks must be updated via widgets.');
142
+ }
115
143
 
116
144
  const name = updates.name || task.name;
117
145
  const cronExpr = updates.cronExpression || task.cron_expression;
@@ -122,25 +150,34 @@ class Scheduler {
122
150
 
123
151
  // Merge config — start from existing, apply any changes
124
152
  let config = this._normalizeTaskConfig(task.task_config);
125
- if (updates.prompt !== undefined) config.prompt = updates.prompt;
126
- if (updates.callTo !== undefined) config.callTo = updates.callTo || null;
127
- if (updates.callGreeting !== undefined) config.callGreeting = updates.callGreeting || null;
128
- if (updates.model !== undefined) {
129
- if (typeof updates.model === 'string' && updates.model.trim()) {
130
- config.model = updates.model.trim();
131
- } else {
132
- delete config.model;
153
+ if (task.task_type === 'widget_refresh') {
154
+ if (updates.taskConfig !== undefined) {
155
+ config = this._normalizeTaskConfig(updates.taskConfig);
133
156
  }
134
- }
135
- if (!config.notifyPlatform || !config.notifyTo) {
136
- const notifyTarget = this._getDefaultNotifyTarget(userId, agentId);
137
- if (notifyTarget.platform && notifyTarget.to) {
138
- config.notifyPlatform = notifyTarget.platform;
139
- config.notifyTo = notifyTarget.to;
157
+ if (!config.widgetId) {
158
+ throw new Error('widget_refresh tasks require widgetId.');
159
+ }
160
+ } else {
161
+ if (updates.prompt !== undefined) config.prompt = updates.prompt;
162
+ if (updates.callTo !== undefined) config.callTo = updates.callTo || null;
163
+ if (updates.callGreeting !== undefined) config.callGreeting = updates.callGreeting || null;
164
+ if (updates.model !== undefined) {
165
+ if (typeof updates.model === 'string' && updates.model.trim()) {
166
+ config.model = updates.model.trim();
167
+ } else {
168
+ delete config.model;
169
+ }
170
+ }
171
+ if (!config.notifyPlatform || !config.notifyTo) {
172
+ const notifyTarget = this._getDefaultNotifyTarget(userId, agentId);
173
+ if (notifyTarget.platform && notifyTarget.to) {
174
+ config.notifyPlatform = notifyTarget.platform;
175
+ config.notifyTo = notifyTarget.to;
176
+ }
140
177
  }
178
+ // Clean up nulls
179
+ if (!config.callTo) { delete config.callTo; delete config.callGreeting; }
141
180
  }
142
- // Clean up nulls
143
- if (!config.callTo) { delete config.callTo; delete config.callGreeting; }
144
181
 
145
182
  if (updates.cronExpression && !cron.validate(updates.cronExpression)) {
146
183
  throw new Error(`Invalid cron expression: ${updates.cronExpression}`);
@@ -160,12 +197,15 @@ class Scheduler {
160
197
  this._scheduleTask(taskId, userId, cronExpr, config, agentId);
161
198
  }
162
199
 
163
- return { id: taskId, name, cronExpression: cronExpr, enabled, callTo: config.callTo || null, model: config.model || null, agentId };
200
+ return { id: taskId, name, cronExpression: cronExpr, enabled, callTo: config.callTo || null, model: config.model || null, agentId, taskType: task.task_type || 'agent_prompt', widgetId: config.widgetId || null };
164
201
  }
165
202
 
166
- deleteTask(taskId, userId) {
203
+ deleteTask(taskId, userId, options = {}) {
167
204
  const task = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND user_id = ?').get(taskId, userId);
168
205
  if (!task) throw new Error('Task not found');
206
+ if (task.task_type === 'widget_refresh' && options.allowManaged !== true) {
207
+ throw new Error('Widget refresh tasks must be deleted via widgets.');
208
+ }
169
209
 
170
210
  const existing = this.jobs.get(taskId);
171
211
  if (existing) {
@@ -203,6 +243,8 @@ class Scheduler {
203
243
  enabled: !!t.enabled,
204
244
  lastRun: t.last_run,
205
245
  nextRun: t.one_time ? t.run_at : this._getNextRun(t.cron_expression),
246
+ taskType: t.task_type || 'agent_prompt',
247
+ widgetId: config.widgetId || null,
206
248
  config,
207
249
  prompt: config.prompt || '',
208
250
  model: config.model || null,
@@ -308,6 +350,20 @@ class Scheduler {
308
350
  this.io.to(`user:${userId}`).emit('scheduler:task_running', { taskId, timestamp: new Date().toISOString() });
309
351
 
310
352
  try {
353
+ if (task.task_type === 'widget_refresh') {
354
+ const widgetService = this.app?.locals?.widgetService;
355
+ if (!widgetService || !config.widgetId) {
356
+ throw new Error('Widget refresh task is missing widget context.');
357
+ }
358
+ const result = await widgetService.refreshWidget(userId, config.widgetId, {
359
+ taskId,
360
+ manual: executionMeta.manual === true,
361
+ scheduledAt: executionMeta.scheduledAt || null,
362
+ });
363
+ this.io.to(`user:${userId}`).emit('scheduler:task_complete', { taskId, result });
364
+ return result;
365
+ }
366
+
311
367
  if (this.agentEngine && config.prompt !== undefined) {
312
368
  let notifyHint = '';
313
369
 
@@ -458,10 +514,7 @@ class Scheduler {
458
514
 
459
515
  _getNextRun(cronExpression) {
460
516
  try {
461
- const interval = cron.schedule(cronExpression, () => { });
462
- interval.stop();
463
- // node-cron doesn't expose nextRun; we just return null
464
- return null;
517
+ return findNextRun(cronExpression)?.toISOString() || null;
465
518
  } catch {
466
519
  return null;
467
520
  }