neoagent 3.2.0 → 3.2.1-beta.1

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.
@@ -45,6 +45,12 @@ runs:
45
45
  The engine records steps, run events, model usage, timing, and artifacts.
46
46
  Repetition guards and loop limits prevent unbounded retries.
47
47
 
48
+ Run control is checked at model and tool boundaries. Abort and interruption
49
+ are terminal; pause cancels the active model or command, records a checkpoint,
50
+ and parks the in-process run until the authenticated resume action releases it.
51
+ If a state-changing tool is interrupted after dispatch, its outcome is recorded
52
+ as unknown and must be verified before the model can attempt it again.
53
+
48
54
  ## 4. Complete and deliver
49
55
 
50
56
  The final response is sanitized and stored. Messaging-triggered runs send an
@@ -55,6 +61,10 @@ The engine emits `run:complete`, persists prompt and usage metrics, refreshes
55
61
  conversation summaries and working state, and cancels unfinished subagents.
56
62
  Failures and user stops produce separate terminal run states.
57
63
 
64
+ Terminal transitions are first-writer-wins. A late model, tool, verifier, or
65
+ delivery callback cannot overwrite an earlier stop or interruption, and active
66
+ run, step, and delegation rows are settled together.
67
+
58
68
  ## 5. Post-run processing
59
69
 
60
70
  Completed conversations can run structured memory consolidation. The engine
@@ -5087,7 +5087,284 @@ _TaskTriggerOption _taskTriggerOptionForType(String type) {
5087
5087
  );
5088
5088
  }
5089
5089
 
5090
+ class _TaskSchedulePreset {
5091
+ const _TaskSchedulePreset({
5092
+ required this.id,
5093
+ required this.label,
5094
+ required this.description,
5095
+ required this.icon,
5096
+ });
5097
+
5098
+ final String id;
5099
+ final String label;
5100
+ final String description;
5101
+ final IconData icon;
5102
+ }
5103
+
5104
+ const List<_TaskSchedulePreset> _taskSchedulePresets = <_TaskSchedulePreset>[
5105
+ _TaskSchedulePreset(
5106
+ id: 'every_15_minutes',
5107
+ label: 'Every 15 minutes',
5108
+ description: 'Runs four times per hour.',
5109
+ icon: Icons.timer_outlined,
5110
+ ),
5111
+ _TaskSchedulePreset(
5112
+ id: 'every_30_minutes',
5113
+ label: 'Every 30 minutes',
5114
+ description: 'Runs twice per hour.',
5115
+ icon: Icons.timelapse_rounded,
5116
+ ),
5117
+ _TaskSchedulePreset(
5118
+ id: 'hourly',
5119
+ label: 'Hourly',
5120
+ description: 'Runs once per hour.',
5121
+ icon: Icons.schedule_rounded,
5122
+ ),
5123
+ _TaskSchedulePreset(
5124
+ id: 'daily',
5125
+ label: 'Daily',
5126
+ description: 'Runs every day at the selected time.',
5127
+ icon: Icons.today_rounded,
5128
+ ),
5129
+ _TaskSchedulePreset(
5130
+ id: 'weekdays',
5131
+ label: 'Weekdays',
5132
+ description: 'Runs Monday through Friday.',
5133
+ icon: Icons.work_outline_rounded,
5134
+ ),
5135
+ _TaskSchedulePreset(
5136
+ id: 'weekly',
5137
+ label: 'Weekly',
5138
+ description: 'Runs on selected weekdays.',
5139
+ icon: Icons.view_week_rounded,
5140
+ ),
5141
+ _TaskSchedulePreset(
5142
+ id: 'monthly',
5143
+ label: 'Monthly',
5144
+ description: 'Runs once per month on the selected day.',
5145
+ icon: Icons.calendar_month_rounded,
5146
+ ),
5147
+ _TaskSchedulePreset(
5148
+ id: 'custom',
5149
+ label: 'Custom Cron',
5150
+ description: 'Advanced manual schedule for special cases.',
5151
+ icon: Icons.tune_rounded,
5152
+ ),
5153
+ ];
5154
+
5155
+ const List<String> _taskWeekdayLabels = <String>[
5156
+ 'Mon',
5157
+ 'Tue',
5158
+ 'Wed',
5159
+ 'Thu',
5160
+ 'Fri',
5161
+ 'Sat',
5162
+ 'Sun',
5163
+ ];
5164
+
5165
+ _TaskSchedulePreset _taskSchedulePresetForId(String id) {
5166
+ return _taskSchedulePresets.firstWhere(
5167
+ (entry) => entry.id == id,
5168
+ orElse: () => _taskSchedulePresets[1],
5169
+ );
5170
+ }
5171
+
5172
+ class _TaskScheduleDraft {
5173
+ _TaskScheduleDraft({
5174
+ required this.mode,
5175
+ required this.presetId,
5176
+ required this.time,
5177
+ required this.weekdays,
5178
+ required this.monthDay,
5179
+ required this.customCronExpression,
5180
+ });
5181
+
5182
+ factory _TaskScheduleDraft.fromTask(TaskItem? task) {
5183
+ final runAt = task?.triggerConfig['runAt']?.toString().trim() ?? '';
5184
+ if (runAt.isNotEmpty) {
5185
+ return _TaskScheduleDraft(
5186
+ mode: 'one_time',
5187
+ presetId: 'daily',
5188
+ time: const TimeOfDay(hour: 9, minute: 0),
5189
+ weekdays: <int>{1},
5190
+ monthDay: 1,
5191
+ customCronExpression: '',
5192
+ );
5193
+ }
5194
+ final cron =
5195
+ task?.triggerConfig['cronExpression']?.toString().trim() ??
5196
+ '*/30 * * * *';
5197
+ final parsed = _parseCronExpression(cron);
5198
+ if (parsed != null) return parsed;
5199
+ return _TaskScheduleDraft(
5200
+ mode: 'recurring',
5201
+ presetId: 'custom',
5202
+ time: const TimeOfDay(hour: 9, minute: 0),
5203
+ weekdays: <int>{1},
5204
+ monthDay: 1,
5205
+ customCronExpression: cron,
5206
+ );
5207
+ }
5208
+
5209
+ String mode;
5210
+ String presetId;
5211
+ TimeOfDay time;
5212
+ Set<int> weekdays;
5213
+ int monthDay;
5214
+ String customCronExpression;
5215
+
5216
+ bool get usesTime =>
5217
+ presetId == 'daily' ||
5218
+ presetId == 'weekdays' ||
5219
+ presetId == 'weekly' ||
5220
+ presetId == 'monthly';
5221
+
5222
+ String get cronExpression {
5223
+ if (presetId == 'custom') return customCronExpression;
5224
+ final minute = time.minute.toString();
5225
+ final hour = time.hour.toString();
5226
+ return switch (presetId) {
5227
+ 'every_15_minutes' => '*/15 * * * *',
5228
+ 'every_30_minutes' => '*/30 * * * *',
5229
+ 'hourly' => '0 * * * *',
5230
+ 'daily' => '$minute $hour * * *',
5231
+ 'weekdays' => '$minute $hour * * 1-5',
5232
+ 'weekly' => '$minute $hour * * ${_cronWeekdays(weekdays)}',
5233
+ 'monthly' => '$minute $hour $monthDay * *',
5234
+ 'custom' => customCronExpression,
5235
+ _ => '*/30 * * * *',
5236
+ };
5237
+ }
5238
+
5239
+ String get summary {
5240
+ if (mode == 'one_time') return 'One-time run';
5241
+ if (presetId == 'custom') return 'Custom Cron';
5242
+ final preset = _taskSchedulePresetForId(presetId);
5243
+ if (!usesTime) return preset.label;
5244
+ final timeLabel = _formatTaskScheduleTime(time);
5245
+ if (presetId == 'weekly') {
5246
+ return '${preset.label} ${_formatTaskWeekdays(weekdays)} at $timeLabel';
5247
+ }
5248
+ if (presetId == 'monthly') {
5249
+ return '${preset.label} on day $monthDay at $timeLabel';
5250
+ }
5251
+ return '${preset.label} at $timeLabel';
5252
+ }
5253
+ }
5254
+
5255
+ _TaskScheduleDraft? _parseCronExpression(String cron) {
5256
+ final fields = cron.trim().split(RegExp(r'\s+'));
5257
+ if (fields.length != 5) return null;
5258
+ final minute = fields[0];
5259
+ final hour = fields[1];
5260
+ final dayOfMonth = fields[2];
5261
+ final month = fields[3];
5262
+ final dayOfWeek = fields[4];
5263
+ if (cron == '*/15 * * * *') {
5264
+ return _recurringScheduleDraft('every_15_minutes');
5265
+ }
5266
+ if (cron == '*/30 * * * *') {
5267
+ return _recurringScheduleDraft('every_30_minutes');
5268
+ }
5269
+ if (cron == '0 * * * *') return _recurringScheduleDraft('hourly');
5270
+ final parsedMinute = int.tryParse(minute);
5271
+ final parsedHour = int.tryParse(hour);
5272
+ if (parsedMinute == null ||
5273
+ parsedMinute < 0 ||
5274
+ parsedMinute > 59 ||
5275
+ parsedHour == null ||
5276
+ parsedHour < 0 ||
5277
+ parsedHour > 23 ||
5278
+ month != '*') {
5279
+ return null;
5280
+ }
5281
+ final time = TimeOfDay(hour: parsedHour, minute: parsedMinute);
5282
+ if (dayOfMonth == '*' && dayOfWeek == '*') {
5283
+ return _recurringScheduleDraft('daily', time: time);
5284
+ }
5285
+ if (dayOfMonth == '*' && dayOfWeek == '1-5') {
5286
+ return _recurringScheduleDraft('weekdays', time: time);
5287
+ }
5288
+ if (dayOfMonth == '*') {
5289
+ final weekdays = _parseCronWeekdays(dayOfWeek);
5290
+ if (weekdays == null || weekdays.isEmpty) return null;
5291
+ return _recurringScheduleDraft('weekly', time: time, weekdays: weekdays);
5292
+ }
5293
+ if (dayOfWeek == '*') {
5294
+ final parsedDay = int.tryParse(dayOfMonth);
5295
+ if (parsedDay == null || parsedDay < 1 || parsedDay > 31) return null;
5296
+ return _recurringScheduleDraft('monthly', time: time, monthDay: parsedDay);
5297
+ }
5298
+ return null;
5299
+ }
5300
+
5301
+ bool _looksLikeCronExpression(String cron) {
5302
+ return cron.trim().split(RegExp(r'\s+')).length == 5;
5303
+ }
5304
+
5305
+ _TaskScheduleDraft _recurringScheduleDraft(
5306
+ String presetId, {
5307
+ TimeOfDay time = const TimeOfDay(hour: 9, minute: 0),
5308
+ Set<int> weekdays = const <int>{1},
5309
+ int monthDay = 1,
5310
+ }) {
5311
+ return _TaskScheduleDraft(
5312
+ mode: 'recurring',
5313
+ presetId: presetId,
5314
+ time: time,
5315
+ weekdays: Set<int>.from(weekdays),
5316
+ monthDay: monthDay,
5317
+ customCronExpression: '',
5318
+ );
5319
+ }
5320
+
5321
+ Set<int>? _parseCronWeekdays(String value) {
5322
+ final result = <int>{};
5323
+ for (final part in value.split(',')) {
5324
+ final trimmed = part.trim();
5325
+ if (trimmed.isEmpty) return null;
5326
+ final rangeParts = trimmed.split('-');
5327
+ if (rangeParts.length == 2) {
5328
+ final start = _parseCronWeekday(rangeParts[0]);
5329
+ final end = _parseCronWeekday(rangeParts[1]);
5330
+ if (start == null || end == null || start > end) return null;
5331
+ for (var day = start; day <= end; day += 1) {
5332
+ result.add(day);
5333
+ }
5334
+ } else if (rangeParts.length == 1) {
5335
+ final day = _parseCronWeekday(trimmed);
5336
+ if (day == null) return null;
5337
+ result.add(day);
5338
+ } else {
5339
+ return null;
5340
+ }
5341
+ }
5342
+ return result;
5343
+ }
5344
+
5345
+ int? _parseCronWeekday(String value) {
5346
+ final parsed = int.tryParse(value.trim());
5347
+ if (parsed == null || parsed < 0 || parsed > 7) return null;
5348
+ return parsed == 0 ? 7 : parsed;
5349
+ }
5090
5350
 
5351
+ String _cronWeekdays(Set<int> weekdays) {
5352
+ final sorted = weekdays.where((day) => day >= 1 && day <= 7).toList()..sort();
5353
+ if (sorted.isEmpty) return '1';
5354
+ return sorted.join(',');
5355
+ }
5356
+
5357
+ String _formatTaskScheduleTime(TimeOfDay time) {
5358
+ final hour = time.hour.toString().padLeft(2, '0');
5359
+ final minute = time.minute.toString().padLeft(2, '0');
5360
+ return '$hour:$minute';
5361
+ }
5362
+
5363
+ String _formatTaskWeekdays(Set<int> weekdays) {
5364
+ final sorted = weekdays.where((day) => day >= 1 && day <= 7).toList()..sort();
5365
+ if (sorted.isEmpty) return 'Monday';
5366
+ return sorted.map((day) => _taskWeekdayLabels[day - 1]).join(', ');
5367
+ }
5091
5368
  Future<String?> _pickTaskTriggerType(
5092
5369
  BuildContext context,
5093
5370
  String selectedType,
@@ -6255,8 +6532,9 @@ class _TasksPanelState extends State<TasksPanel> {
6255
6532
  }) async {
6256
6533
  final nameController = TextEditingController(text: task?.name ?? '');
6257
6534
  final triggerType = ValueNotifier<String>(task?.triggerType ?? 'schedule');
6258
- final cronController = TextEditingController(
6259
- text: task?.triggerConfig['cronExpression']?.toString() ?? '*/30 * * * *',
6535
+ final scheduleDraft = _TaskScheduleDraft.fromTask(task);
6536
+ final customCronController = TextEditingController(
6537
+ text: scheduleDraft.customCronExpression,
6260
6538
  );
6261
6539
  final runAtController = TextEditingController(
6262
6540
  text: task?.triggerConfig['runAt']?.toString() ?? '',
@@ -6463,21 +6741,246 @@ class _TasksPanelState extends State<TasksPanel> {
6463
6741
  if (selectedTriggerType == 'schedule') {
6464
6742
  return Column(
6465
6743
  children: <Widget>[
6466
- TextField(
6467
- controller: cronController,
6468
- decoration: const InputDecoration(
6469
- labelText: 'Cron Expression',
6470
- helperText:
6471
- 'Use cron for recurring tasks. Leave Run At empty for recurring schedules.',
6472
- ),
6744
+ SegmentedButton<String>(
6745
+ segments: const <ButtonSegment<String>>[
6746
+ ButtonSegment<String>(
6747
+ value: 'recurring',
6748
+ icon: Icon(Icons.repeat_rounded),
6749
+ label: Text('Recurring'),
6750
+ ),
6751
+ ButtonSegment<String>(
6752
+ value: 'one_time',
6753
+ icon: Icon(Icons.event_rounded),
6754
+ label: Text('Once'),
6755
+ ),
6756
+ ],
6757
+ selected: <String>{scheduleDraft.mode},
6758
+ onSelectionChanged: (selection) {
6759
+ setLocalState(() {
6760
+ scheduleDraft.mode = selection.first;
6761
+ if (scheduleDraft.mode == 'recurring') {
6762
+ runAtController.clear();
6763
+ }
6764
+ });
6765
+ },
6473
6766
  ),
6474
6767
  const SizedBox(height: 12),
6475
- TextField(
6476
- controller: runAtController,
6477
- decoration: const InputDecoration(
6478
- labelText: 'Run At (optional ISO datetime)',
6768
+ if (scheduleDraft.mode == 'one_time')
6769
+ TextField(
6770
+ controller: runAtController,
6771
+ decoration: const InputDecoration(
6772
+ labelText: 'Run At',
6773
+ helperText:
6774
+ 'Use a date and time, for example 2026-07-03T09:00:00.',
6775
+ ),
6776
+ )
6777
+ else ...<Widget>[
6778
+ DropdownButtonFormField<String>(
6779
+ initialValue: scheduleDraft.presetId,
6780
+ isExpanded: true,
6781
+ decoration: const InputDecoration(
6782
+ labelText: 'Repeat',
6783
+ ),
6784
+ items: <DropdownMenuItem<String>>[
6785
+ ..._taskSchedulePresets.map(
6786
+ (preset) => DropdownMenuItem<String>(
6787
+ value: preset.id,
6788
+ child: Text(preset.label),
6789
+ ),
6790
+ ),
6791
+ ],
6792
+ onChanged: (value) {
6793
+ if (value == null) return;
6794
+ setLocalState(() {
6795
+ final currentCron =
6796
+ scheduleDraft.cronExpression;
6797
+ scheduleDraft.presetId = value;
6798
+ if (value == 'custom' &&
6799
+ customCronController.text
6800
+ .trim()
6801
+ .isEmpty) {
6802
+ customCronController.text =
6803
+ currentCron;
6804
+ scheduleDraft.customCronExpression =
6805
+ currentCron;
6806
+ }
6807
+ });
6808
+ },
6479
6809
  ),
6480
- ),
6810
+ const SizedBox(height: 12),
6811
+ if (scheduleDraft.presetId ==
6812
+ 'custom') ...<Widget>[
6813
+ TextField(
6814
+ controller: customCronController,
6815
+ onChanged: (value) =>
6816
+ scheduleDraft.customCronExpression =
6817
+ value,
6818
+ decoration: const InputDecoration(
6819
+ labelText: 'Cron expression',
6820
+ helperText:
6821
+ 'Advanced: minute hour day month weekday.',
6822
+ ),
6823
+ ),
6824
+ const SizedBox(height: 12),
6825
+ ],
6826
+ if (scheduleDraft.usesTime) ...<Widget>[
6827
+ InkWell(
6828
+ borderRadius: BorderRadius.circular(14),
6829
+ onTap: () async {
6830
+ final picked = await showTimePicker(
6831
+ context: context,
6832
+ initialTime: scheduleDraft.time,
6833
+ builder: (context, child) {
6834
+ return MediaQuery(
6835
+ data: MediaQuery.of(context)
6836
+ .copyWith(
6837
+ alwaysUse24HourFormat: true,
6838
+ ),
6839
+ child: child!,
6840
+ );
6841
+ },
6842
+ );
6843
+ if (picked == null) return;
6844
+ setLocalState(() {
6845
+ scheduleDraft.time = picked;
6846
+ });
6847
+ },
6848
+ child: InputDecorator(
6849
+ decoration: const InputDecoration(
6850
+ labelText: 'Time',
6851
+ ),
6852
+ child: Row(
6853
+ children: <Widget>[
6854
+ const Icon(
6855
+ Icons.access_time_rounded,
6856
+ ),
6857
+ const SizedBox(width: 12),
6858
+ Expanded(
6859
+ child: Text(
6860
+ _formatTaskScheduleTime(
6861
+ scheduleDraft.time,
6862
+ ),
6863
+ style: const TextStyle(
6864
+ fontWeight: FontWeight.w700,
6865
+ ),
6866
+ ),
6867
+ ),
6868
+ Icon(
6869
+ Icons.unfold_more_rounded,
6870
+ color: _textSecondary,
6871
+ ),
6872
+ ],
6873
+ ),
6874
+ ),
6875
+ ),
6876
+ const SizedBox(height: 12),
6877
+ ],
6878
+ if (scheduleDraft.presetId ==
6879
+ 'weekly') ...<Widget>[
6880
+ Align(
6881
+ alignment: Alignment.centerLeft,
6882
+ child: Wrap(
6883
+ spacing: 8,
6884
+ runSpacing: 8,
6885
+ children: List<Widget>.generate(7, (
6886
+ index,
6887
+ ) {
6888
+ final day = index + 1;
6889
+ return ChoiceChip(
6890
+ label: Text(
6891
+ _taskWeekdayLabels[index],
6892
+ ),
6893
+ selected: scheduleDraft.weekdays
6894
+ .contains(day),
6895
+ onSelected: (selected) {
6896
+ setLocalState(() {
6897
+ if (selected) {
6898
+ scheduleDraft.weekdays.add(
6899
+ day,
6900
+ );
6901
+ } else if (scheduleDraft
6902
+ .weekdays
6903
+ .length >
6904
+ 1) {
6905
+ scheduleDraft.weekdays.remove(
6906
+ day,
6907
+ );
6908
+ }
6909
+ });
6910
+ },
6911
+ );
6912
+ }),
6913
+ ),
6914
+ ),
6915
+ const SizedBox(height: 12),
6916
+ ],
6917
+ if (scheduleDraft.presetId ==
6918
+ 'monthly') ...<Widget>[
6919
+ DropdownButtonFormField<int>(
6920
+ initialValue: scheduleDraft.monthDay,
6921
+ decoration: const InputDecoration(
6922
+ labelText: 'Day of month',
6923
+ ),
6924
+ items:
6925
+ List<DropdownMenuItem<int>>.generate(
6926
+ 31,
6927
+ (index) => DropdownMenuItem<int>(
6928
+ value: index + 1,
6929
+ child: Text('Day ${index + 1}'),
6930
+ ),
6931
+ ),
6932
+ onChanged: (value) {
6933
+ if (value == null) return;
6934
+ setLocalState(() {
6935
+ scheduleDraft.monthDay = value;
6936
+ });
6937
+ },
6938
+ ),
6939
+ const SizedBox(height: 12),
6940
+ ],
6941
+ InputDecorator(
6942
+ decoration: const InputDecoration(
6943
+ labelText: 'Schedule',
6944
+ ),
6945
+ child: Builder(
6946
+ builder: (context) {
6947
+ final preset = _taskSchedulePresetForId(
6948
+ scheduleDraft.presetId,
6949
+ );
6950
+ return Row(
6951
+ children: <Widget>[
6952
+ Icon(preset.icon, color: _accent),
6953
+ const SizedBox(width: 12),
6954
+ Expanded(
6955
+ child: Column(
6956
+ crossAxisAlignment:
6957
+ CrossAxisAlignment.start,
6958
+ mainAxisSize: MainAxisSize.min,
6959
+ children: <Widget>[
6960
+ Text(
6961
+ scheduleDraft.summary,
6962
+ style: const TextStyle(
6963
+ fontWeight:
6964
+ FontWeight.w700,
6965
+ ),
6966
+ ),
6967
+ const SizedBox(height: 3),
6968
+ Text(
6969
+ preset.description,
6970
+ style: TextStyle(
6971
+ color: _textSecondary,
6972
+ fontSize: 12,
6973
+ ),
6974
+ ),
6975
+ ],
6976
+ ),
6977
+ ),
6978
+ ],
6979
+ );
6980
+ },
6981
+ ),
6982
+ ),
6983
+ ],
6481
6984
  ],
6482
6985
  );
6483
6986
  }
@@ -6835,13 +7338,44 @@ class _TasksPanelState extends State<TasksPanel> {
6835
7338
  // Manual trigger uses no trigger-specific config.
6836
7339
  } else if (selectedTriggerType == 'schedule') {
6837
7340
  final runAt = runAtController.text.trim();
6838
- triggerConfig['mode'] = runAt.isEmpty
6839
- ? 'recurring'
6840
- : 'one_time';
6841
- if (runAt.isEmpty) {
6842
- triggerConfig['cronExpression'] = cronController.text
7341
+ triggerConfig['mode'] = scheduleDraft.mode;
7342
+ if (scheduleDraft.mode == 'recurring') {
7343
+ scheduleDraft.customCronExpression =
7344
+ customCronController.text.trim();
7345
+ final cronExpression = scheduleDraft.cronExpression
6843
7346
  .trim();
7347
+ if (cronExpression.isEmpty) {
7348
+ ScaffoldMessenger.of(context).showSnackBar(
7349
+ const SnackBar(
7350
+ content: Text('Please choose a schedule.'),
7351
+ backgroundColor: Colors.red,
7352
+ ),
7353
+ );
7354
+ return;
7355
+ }
7356
+ if (scheduleDraft.presetId == 'custom' &&
7357
+ !_looksLikeCronExpression(cronExpression)) {
7358
+ ScaffoldMessenger.of(context).showSnackBar(
7359
+ const SnackBar(
7360
+ content: Text('Custom Cron must have 5 fields.'),
7361
+ backgroundColor: Colors.red,
7362
+ ),
7363
+ );
7364
+ return;
7365
+ }
7366
+ triggerConfig['cronExpression'] = cronExpression;
6844
7367
  } else {
7368
+ if (runAt.isEmpty) {
7369
+ ScaffoldMessenger.of(context).showSnackBar(
7370
+ const SnackBar(
7371
+ content: Text(
7372
+ 'Please enter when the task should run.',
7373
+ ),
7374
+ backgroundColor: Colors.red,
7375
+ ),
7376
+ );
7377
+ return;
7378
+ }
6845
7379
  triggerConfig['runAt'] = runAt;
6846
7380
  }
6847
7381
  } else {
@@ -633,6 +633,34 @@ function migrateBilling(db) {
633
633
  `);
634
634
  }
635
635
 
636
+ function migrateAgentRunLifecycle(db) {
637
+ db.exec(`
638
+ CREATE TABLE IF NOT EXISTS agent_run_controls (
639
+ run_id TEXT PRIMARY KEY,
640
+ user_id INTEGER NOT NULL,
641
+ action TEXT NOT NULL CHECK(action IN ('pause', 'stop', 'interrupt')),
642
+ reason TEXT DEFAULT '',
643
+ requested_at TEXT DEFAULT (datetime('now')),
644
+ consumed_at TEXT,
645
+ FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
646
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
647
+ );
648
+
649
+ CREATE TABLE IF NOT EXISTS agent_run_checkpoints (
650
+ run_id TEXT PRIMARY KEY,
651
+ version INTEGER NOT NULL DEFAULT 1,
652
+ phase TEXT NOT NULL,
653
+ state_json TEXT NOT NULL DEFAULT '{}',
654
+ created_at TEXT DEFAULT (datetime('now')),
655
+ updated_at TEXT DEFAULT (datetime('now')),
656
+ FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
657
+ );
658
+
659
+ CREATE INDEX IF NOT EXISTS idx_agent_run_controls_pending
660
+ ON agent_run_controls(user_id, consumed_at, requested_at);
661
+ `);
662
+ }
663
+
636
664
  function runSchemaMigrations(db) {
637
665
  removeRetiredCaptureData(db);
638
666
  migrateMemoryEmbeddingIndex(db);
@@ -647,6 +675,7 @@ function runSchemaMigrations(db) {
647
675
  migrateApprovalPersistence(db);
648
676
  migrateToolPoliciesAllowAlways(db);
649
677
  migrateBilling(db);
678
+ migrateAgentRunLifecycle(db);
650
679
  }
651
680
 
652
681
  module.exports = {
@@ -664,5 +693,6 @@ module.exports = {
664
693
  migrateApprovalPersistence,
665
694
  migrateToolPoliciesAllowAlways,
666
695
  migrateBilling,
696
+ migrateAgentRunLifecycle,
667
697
  runSchemaMigrations,
668
698
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.2.0",
3
+ "version": "3.2.1-beta.1",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",