feat: 统一自动化任务调度配置并增强聊天流恢复

This commit is contained in:
qzl
2026-03-24 18:19:33 +08:00
parent 23359c2d01
commit 389f5248fc
30 changed files with 1144 additions and 888 deletions
@@ -187,6 +187,7 @@ class AgUiService {
String? eventType;
String? eventId;
var hasBoundExpectedRun = false;
var hasSeenTerminalForRun = false;
final dataBuffer = StringBuffer();
final done = Completer<void>();
late final StreamSubscription<String> subscription;
@@ -257,6 +258,7 @@ class AgUiService {
eventThreadId == null || eventThreadId == threadId;
if (isTerminalEvent &&
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
hasSeenTerminalForRun = true;
stopStream();
return;
}
@@ -291,6 +293,16 @@ class AgUiService {
stopStream(error: error, stackTrace: stackTrace);
},
onDone: () {
if (streamToken != _activeStreamToken) {
stopStream();
return;
}
if (!hasSeenTerminalForRun) {
stopStream(
error: StateError('SSE closed before terminal event for run'),
);
return;
}
stopStream();
},
cancelOnError: false,
@@ -1,7 +1,9 @@
enum AgentStage { execution, memory }
enum AgentStage { routing, execution, memory }
AgentStage? stageFromStepName(String value) {
switch (value) {
case 'router':
return AgentStage.routing;
case 'worker':
return AgentStage.execution;
case 'memory':
@@ -13,6 +15,7 @@ AgentStage? stageFromStepName(String value) {
String stageLabel(AgentStage? stage) {
return switch (stage) {
AgentStage.routing => '意图识别中',
AgentStage.execution => '任务执行中',
AgentStage.memory => '记忆提取中',
null => '任务处理中',
@@ -408,6 +408,11 @@ class ChatBloc extends Cubit<ChatState> {
uploadedAttachments: sendResult.uploadedAttachments,
);
} catch (error) {
final sseClosedBeforeTerminal = _isSseClosedBeforeTerminalError(error);
var recoveredFromHistory = false;
if (sseClosedBeforeTerminal) {
recoveredFromHistory = await _recoverFromAbnormalSseClose();
}
_markAttachmentUploadDone(messageId);
emit(
state.copyWith(
@@ -415,12 +420,45 @@ class ChatBloc extends Cubit<ChatState> {
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
error: error.toString(),
currentStage: null,
error: sseClosedBeforeTerminal
? (recoveredFromHistory ? null : '连接中断,请重试')
: error.toString(),
),
);
}
}
bool _isSseClosedBeforeTerminalError(Object error) {
final text = error.toString().toLowerCase();
return text.contains('sse closed before terminal event');
}
Future<bool> _recoverFromAbnormalSseClose() async {
try {
final snapshot = await _service.loadHistory();
final historyItems = _convertHistoryMessages(snapshot.messages);
final mergedById = <String, ChatListItem>{
for (final item in historyItems) item.id: item,
};
for (final item in state.items) {
mergedById[item.id] = item;
}
final merged = mergedById.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
emit(
state.copyWith(
items: merged,
oldestLoadedDate: _extractDateFromItems(merged),
hasEarlierHistory: snapshot.hasMore,
),
);
return true;
} catch (_) {
return false;
}
}
void _syncUploadedAttachments({
required String messageId,
required List<UploadedAttachment> uploadedAttachments,
@@ -484,10 +522,18 @@ class ChatBloc extends Cubit<ChatState> {
try {
final snapshot = await _service.loadHistory();
final newItems = _convertHistoryMessages(snapshot.messages);
final oldestDate = _extractDateFromItems(newItems);
final mergedById = <String, ChatListItem>{
for (final item in newItems) item.id: item,
};
for (final item in state.items) {
mergedById[item.id] = item;
}
final merged = mergedById.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final oldestDate = _extractDateFromItems(merged);
emit(
state.copyWith(
items: newItems,
items: merged,
oldestLoadedDate: oldestDate,
hasEarlierHistory: snapshot.hasMore,
),
@@ -92,15 +92,95 @@ class MessageContextConfigModel {
}
}
class ScheduleRunAtModel {
final int hour;
final int minute;
ScheduleRunAtModel({required this.hour, required this.minute});
factory ScheduleRunAtModel.fromJson(Map<String, dynamic>? json) {
if (json == null) {
return ScheduleRunAtModel(hour: 8, minute: 0);
}
return ScheduleRunAtModel(
hour: _parseInt(json['hour'], field: 'hour', fallback: 8),
minute: _parseInt(json['minute'], field: 'minute', fallback: 0),
);
}
Map<String, dynamic> toJson() => {'hour': hour, 'minute': minute};
ScheduleRunAtModel copyWith({int? hour, int? minute}) {
return ScheduleRunAtModel(
hour: hour ?? this.hour,
minute: minute ?? this.minute,
);
}
}
class ScheduleConfigModel {
final String type;
final ScheduleRunAtModel runAt;
final List<int>? weekdays;
ScheduleConfigModel({required this.type, required this.runAt, this.weekdays});
factory ScheduleConfigModel.fromJson(Map<String, dynamic>? json) {
if (json == null) {
return ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
);
}
final type = _parseString(json['type'], field: 'type', fallback: 'daily');
final dynamic weekdaysRaw = json['weekdays'];
List<int>? weekdays;
if (weekdaysRaw is List) {
weekdays = weekdaysRaw
.map((item) => _parseInt(item, field: 'weekdays', fallback: 1))
.toList();
}
return ScheduleConfigModel(
type: type,
runAt: ScheduleRunAtModel.fromJson(
json['run_at'] as Map<String, dynamic>?,
),
weekdays: type.toLowerCase() == 'weekly' ? weekdays ?? [1] : null,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{'type': type, 'run_at': runAt.toJson()};
if (weekdays != null) {
map['weekdays'] = weekdays;
}
return map;
}
ScheduleConfigModel copyWith({
String? type,
ScheduleRunAtModel? runAt,
List<int>? weekdays,
}) {
return ScheduleConfigModel(
type: type ?? this.type,
runAt: runAt ?? this.runAt,
weekdays: weekdays ?? this.weekdays,
);
}
}
class AutomationJobConfigModel {
final String inputTemplate;
final List<String> enabledTools;
final MessageContextConfigModel context;
final ScheduleConfigModel schedule;
AutomationJobConfigModel({
required this.inputTemplate,
required this.enabledTools,
required this.context,
required this.schedule,
});
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
@@ -109,6 +189,7 @@ class AutomationJobConfigModel {
inputTemplate: '',
enabledTools: const [],
context: MessageContextConfigModel.fromJson(null),
schedule: ScheduleConfigModel.fromJson(null),
);
}
return AutomationJobConfigModel(
@@ -126,6 +207,11 @@ class AutomationJobConfigModel {
json['context'] as Map<String, dynamic>?,
)
: MessageContextConfigModel.fromJson(null),
schedule: json['schedule'] != null
? ScheduleConfigModel.fromJson(
json['schedule'] as Map<String, dynamic>?,
)
: ScheduleConfigModel.fromJson(null),
);
}
@@ -133,17 +219,20 @@ class AutomationJobConfigModel {
'input_template': inputTemplate,
'enabled_tools': enabledTools,
'context': context.toJson(),
'schedule': schedule.toJson(),
};
AutomationJobConfigModel copyWith({
String? inputTemplate,
List<String>? enabledTools,
MessageContextConfigModel? context,
ScheduleConfigModel? schedule,
}) {
return AutomationJobConfigModel(
inputTemplate: inputTemplate ?? this.inputTemplate,
enabledTools: enabledTools ?? this.enabledTools,
context: context ?? this.context,
schedule: schedule ?? this.schedule,
);
}
}
@@ -153,8 +242,6 @@ class AutomationJobModel {
final String ownerId;
final String? bootstrapKey;
final String title;
final String scheduleType;
final String runAt;
final String timezone;
final String status;
final bool isSystem;
@@ -169,8 +256,6 @@ class AutomationJobModel {
required this.ownerId,
this.bootstrapKey,
required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone,
required this.status,
required this.isSystem,
@@ -193,16 +278,6 @@ class AutomationJobModel {
fallback: '',
),
title: _parseString(json['title'], field: 'title', fallback: ''),
scheduleType: _parseString(
json['schedule_type'],
field: 'schedule_type',
fallback: 'daily',
),
runAt: _parseString(
json['run_at'],
field: 'run_at',
fallback: '08:00:00',
),
timezone: _parseString(
json['timezone'],
field: 'timezone',
@@ -263,8 +338,6 @@ class AutomationJobModel {
'owner_id': ownerId,
'bootstrap_key': bootstrapKey,
'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone,
'status': status,
'is_system': isSystem,
@@ -280,8 +353,6 @@ class AutomationJobModel {
String? ownerId,
String? bootstrapKey,
String? title,
String? scheduleType,
String? runAt,
String? timezone,
String? status,
bool? isSystem,
@@ -296,8 +367,6 @@ class AutomationJobModel {
ownerId: ownerId ?? this.ownerId,
bootstrapKey: bootstrapKey ?? this.bootstrapKey,
title: title ?? this.title,
scheduleType: scheduleType ?? this.scheduleType,
runAt: runAt ?? this.runAt,
timezone: timezone ?? this.timezone,
status: status ?? this.status,
isSystem: isSystem ?? this.isSystem,
@@ -311,9 +380,9 @@ class AutomationJobModel {
bool get isActive => status.toLowerCase() == 'active';
bool get isDaily => scheduleType.toLowerCase() == 'daily';
bool get isDaily => config.schedule.type.toLowerCase() == 'daily';
bool get isWeekly => scheduleType.toLowerCase() == 'weekly';
bool get isWeekly => config.schedule.type.toLowerCase() == 'weekly';
}
class AutomationJobListResponse {
@@ -342,16 +411,12 @@ class AutomationJobListResponse {
class AutomationJobCreateRequest {
final String title;
final String scheduleType;
final String runAt;
final String timezone;
final String status;
final AutomationJobConfigModel config;
AutomationJobCreateRequest({
required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone,
required this.status,
required this.config,
@@ -359,8 +424,6 @@ class AutomationJobCreateRequest {
Map<String, dynamic> toJson() => {
'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone,
'status': status,
'config': config.toJson(),
@@ -371,11 +434,13 @@ class AutomationJobConfigPatchModel {
final String? inputTemplate;
final List<String>? enabledTools;
final MessageContextConfigModel? context;
final ScheduleConfigModel? schedule;
AutomationJobConfigPatchModel({
this.inputTemplate,
this.enabledTools,
this.context,
this.schedule,
});
Map<String, dynamic> toJson() {
@@ -383,22 +448,19 @@ class AutomationJobConfigPatchModel {
if (inputTemplate != null) map['input_template'] = inputTemplate;
if (enabledTools != null) map['enabled_tools'] = enabledTools;
if (context != null) map['context'] = context!.toJson();
if (schedule != null) map['schedule'] = schedule!.toJson();
return map;
}
}
class AutomationJobUpdateRequest {
final String? title;
final String? scheduleType;
final String? runAt;
final String? timezone;
final String? status;
final AutomationJobConfigPatchModel? config;
AutomationJobUpdateRequest({
this.title,
this.scheduleType,
this.runAt,
this.timezone,
this.status,
this.config,
@@ -407,8 +469,6 @@ class AutomationJobUpdateRequest {
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
if (title != null) map['title'] = title;
if (scheduleType != null) map['schedule_type'] = scheduleType;
if (runAt != null) map['run_at'] = runAt;
if (timezone != null) map['timezone'] = timezone;
if (status != null) map['status'] = status;
if (config != null) map['config'] = config!.toJson();
@@ -40,6 +40,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
String _scheduleType = 'daily';
String _timezone = 'Asia/Shanghai';
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
final Set<int> _selectedWeekdays = <int>{1};
String _contextSource = 'latest_chat';
String _contextWindowMode = 'day';
int _contextWindowCount = 2;
@@ -134,8 +135,8 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
_buildSectionTitle('计划配置'),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)),
_buildInfoRow('执行时间', _displayRunAt(job.runAt)),
_buildInfoRow('周期', _scheduleLabel(job.config.schedule.type)),
_buildInfoRow('执行时间', _displayRunAt(job.config.schedule)),
_buildInfoRow('时区', job.timezone),
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
]),
@@ -239,7 +240,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
children: [
_buildBadge(job.isSystem ? '系统预置' : '自定义'),
_buildBadge(job.isActive ? '已启用' : '未启用'),
_buildBadge(_scheduleLabel(job.scheduleType)),
_buildBadge(_scheduleLabel(job.config.schedule.type)),
],
),
],
@@ -319,6 +320,10 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
value: _scheduleLabel(_scheduleType),
onTap: _pickScheduleType,
),
if (_scheduleType == 'weekly') ...[
const SizedBox(height: AppSpacing.sm),
_buildWeekdaySelector(),
],
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '执行时间',
@@ -536,6 +541,85 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
);
}
Widget _buildWeekdaySelector() {
const weekdayLabels = <int, String>{
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'执行日',
style: TextStyle(
color: AppColors.slate500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: weekdayLabels.entries.map((entry) {
final selected = _selectedWeekdays.contains(entry.key);
return AppPressable(
onTap: () {
setState(() {
if (selected) {
if (_selectedWeekdays.length > 1) {
_selectedWeekdays.remove(entry.key);
}
} else {
_selectedWeekdays.add(entry.key);
}
});
},
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? AppColors.blue50 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected
? AppColors.blue300
: AppColors.borderSecondary,
),
),
child: Text(
entry.value,
style: TextStyle(
color: selected ? AppColors.blue600 : AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}).toList(),
),
],
),
);
}
Widget _buildToolWrap(List<String> tools) {
if (tools.isEmpty) {
return _buildTextBlock('未启用工具');
@@ -641,6 +725,9 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
if (picked != null) {
setState(() {
_scheduleType = picked;
if (_scheduleType == 'weekly' && _selectedWeekdays.isEmpty) {
_selectedWeekdays.add(1);
}
});
}
}
@@ -708,15 +795,10 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
return '$hour:$minute:00';
}
String _displayRunAt(String runAtRaw) {
try {
final dt = DateTime.parse(runAtRaw).toLocal();
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} catch (_) {
return runAtRaw;
}
String _displayRunAt(ScheduleConfigModel schedule) {
final hour = schedule.runAt.hour.toString().padLeft(2, '0');
final minute = schedule.runAt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _scheduleLabel(String scheduleType) {
@@ -757,8 +839,6 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
final request = AutomationJobCreateRequest(
title: title,
scheduleType: _scheduleType,
runAt: _formatTime(_runAt),
timezone: _timezone,
status: 'active',
config: AutomationJobConfigModel(
@@ -769,6 +849,13 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
windowMode: _contextWindowMode,
windowCount: _contextWindowCount,
),
schedule: ScheduleConfigModel(
type: _scheduleType,
runAt: ScheduleRunAtModel(hour: _runAt.hour, minute: _runAt.minute),
weekdays: _scheduleType == 'weekly'
? (_selectedWeekdays.toList()..sort())
: null,
),
),
);
final success = await _cubit.createJob(request);
@@ -273,4 +273,27 @@ void main() {
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
});
test('sendMessage fails when SSE closes before terminal event', () async {
final startedLines = _buildSseEvent(
id: '41',
type: AgUiEventTypeWire.runStarted,
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
);
final service = AgUiService(
apiClient: _FakeApiClient(sseLines: <String>[...startedLines]),
);
await expectLater(
service.sendMessage('hello'),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('SSE closed before terminal event'),
),
),
);
});
}
@@ -3,6 +3,13 @@ import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
void main() {
group('agent stage mapping', () {
test('maps protocol step router to routing stage label', () {
final stage = stageFromStepName('router');
expect(stage, AgentStage.routing);
expect(stageLabel(stage), '意图识别中');
});
test('maps protocol step worker to execution stage label', () {
final stage = stageFromStepName('worker');
@@ -48,6 +48,7 @@ class _FakeAgUiService extends AgUiService {
_FakeAgUiService() : super(apiClient: _NoopApiClient());
Completer<SendMessageResult>? pendingResult;
Completer<HistorySnapshot>? pendingHistory;
Object? nextError;
@override
@@ -67,6 +68,21 @@ class _FakeAgUiService extends AgUiService {
return const SendMessageResult(uploadedAttachments: []);
}
@override
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
final pending = pendingHistory;
if (pending != null) {
return pending.future;
}
return const HistorySnapshot(
scope: 'history_day',
threadId: null,
day: null,
hasMore: false,
messages: <HistoryMessage>[],
);
}
void emitEvent(AgUiEvent event) {
onEvent(event);
}
@@ -189,5 +205,132 @@ void main() {
expect(toolItem.errorMessage, '本次运行已失败');
expect(bloc.state.error, 'runtime execution failed');
});
test('text event with ui schema is rendered into chat items', () {
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
service.emitEvent(
TextMessageEndEvent(
messageId: 'assistant-1',
answer: '这是测试回复',
role: 'assistant',
status: 'success',
uiSchema: {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'children': [
{'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'},
],
},
},
),
);
service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'));
final messages = bloc.state.items.whereType<TextMessageItem>().toList();
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
expect(messages, hasLength(1));
expect(messages.single.content, '这是测试回复');
expect(uiCards, hasLength(1));
expect(uiCards.single.uiSchema['root'], isA<Map<String, dynamic>>());
expect(bloc.state.isWaitingFirstToken, isFalse);
expect(bloc.state.isStreaming, isFalse);
expect(bloc.state.currentStage, isNull);
});
test(
'history loading does not overwrite real-time text and ui events',
() async {
final historyCompleter = Completer<HistorySnapshot>();
service.pendingHistory = historyCompleter;
final loadFuture = bloc.loadHistory();
await Future<void>.delayed(Duration.zero);
service.emitEvent(
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEvent(
TextMessageEndEvent(
messageId: 'assistant-live',
answer: '实时回复',
role: 'assistant',
status: 'success',
uiSchema: {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'children': [
{'type': 'text', 'role': 'body', 'content': '实时 UI 卡片'},
],
},
},
),
);
historyCompleter.complete(
const HistorySnapshot(
scope: 'history_day',
threadId: 'thread-1',
day: '2026-03-24',
hasMore: false,
messages: <HistoryMessage>[],
),
);
await loadFuture;
final texts = bloc.state.items.whereType<TextMessageItem>().toList();
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
expect(texts.map((item) => item.id), contains('assistant-live'));
expect(uiCards.map((item) => item.id), contains('assistant-live-ui'));
},
);
test(
'abnormal SSE close recovers from history without raw bad-state error',
() async {
service.nextError = StateError(
'SSE closed before terminal event for run',
);
service.pendingHistory = Completer<HistorySnapshot>()
..complete(
HistorySnapshot(
scope: 'history_day',
threadId: 'thread-1',
day: '2026-03-24',
hasMore: false,
messages: <HistoryMessage>[
HistoryMessage(
id: 'assistant-history-1',
seq: 2,
role: 'assistant',
content: '历史补偿回复',
timestamp: DateTime(2026, 3, 24, 17, 0, 0),
),
],
),
);
await bloc.sendMessage('你是谁?');
expect(bloc.state.error, isNull);
expect(bloc.state.isWaitingFirstToken, isFalse);
expect(bloc.state.isStreaming, isFalse);
expect(bloc.state.currentStage, isNull);
expect(
bloc.state.items
.whereType<TextMessageItem>()
.map((item) => item.content)
.toList(),
contains('历史补偿回复'),
);
},
);
});
}
@@ -2,73 +2,29 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
void main() {
group('MessageContextConfigModel', () {
test('fromJson parses all fields correctly', () {
final json = {
'source': 'messages',
'window_mode': 'week',
'window_count': 5,
};
final model = MessageContextConfigModel.fromJson(json);
expect(model.source, 'messages');
expect(model.windowMode, 'week');
expect(model.windowCount, 5);
});
test('fromJson uses defaults for missing fields', () {
final model = MessageContextConfigModel.fromJson(null);
expect(model.source, 'latest_chat');
expect(model.windowMode, 'day');
expect(model.windowCount, 2);
});
test('toJson serializes correctly', () {
final model = MessageContextConfigModel(
source: 'messages',
windowMode: 'week',
windowCount: 5,
);
final json = model.toJson();
expect(json['source'], 'messages');
expect(json['window_mode'], 'week');
expect(json['window_count'], 5);
});
});
group('AutomationJobConfigModel', () {
test('fromJson parses all fields correctly', () {
test('fromJson parses schedule correctly', () {
final json = {
'input_template': 'Hello {{name}}',
'enabled_tools': ['tool1', 'tool2'],
'context': {
'source': 'messages',
'window_mode': 'week',
'source': 'latest_chat',
'window_mode': 'day',
'window_count': 5,
},
'schedule': {
'type': 'weekly',
'run_at': {'hour': 9, 'minute': 30},
'weekdays': [1, 3, 5],
},
};
final model = AutomationJobConfigModel.fromJson(json);
expect(model.inputTemplate, 'Hello {{name}}');
expect(model.enabledTools, ['tool1', 'tool2']);
expect(model.context.source, 'messages');
expect(model.context.windowMode, 'week');
expect(model.context.windowCount, 5);
});
test('fromJson uses defaults for null input', () {
final model = AutomationJobConfigModel.fromJson(null);
expect(model.inputTemplate, '');
expect(model.enabledTools, []);
expect(model.context.source, 'latest_chat');
expect(model.context.windowMode, 'day');
expect(model.context.windowCount, 2);
expect(model.schedule.type, 'weekly');
expect(model.schedule.runAt.hour, 9);
expect(model.schedule.runAt.minute, 30);
expect(model.schedule.weekdays, [1, 3, 5]);
});
});
@@ -79,8 +35,6 @@ void main() {
'owner_id': 'user-456',
'bootstrap_key': 'key-789',
'title': 'Daily Report',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'America/New_York',
'status': 'ACTIVE',
'is_system': false,
@@ -92,6 +46,10 @@ void main() {
'window_mode': 'day',
'window_count': 2,
},
'schedule': {
'type': 'daily',
'run_at': {'hour': 9, 'minute': 0},
},
},
'next_run_at': '2024-01-15T09:00:00Z',
'last_run_at': '2024-01-14T09:00:00Z',
@@ -103,117 +61,19 @@ void main() {
expect(model.id, 'job-123');
expect(model.ownerId, 'user-456');
expect(model.bootstrapKey, 'key-789');
expect(model.title, 'Daily Report');
expect(model.scheduleType, 'DAILY');
expect(model.runAt, '09:00:00');
expect(model.config.schedule.type, 'daily');
expect(model.config.schedule.runAt.hour, 9);
expect(model.timezone, 'America/New_York');
expect(model.status, 'ACTIVE');
expect(model.isSystem, false);
expect(model.config.inputTemplate, 'Hello');
expect(model.config.enabledTools, ['tool1']);
expect(model.config.context.windowCount, 2);
expect(model.nextRunAt, DateTime.parse('2024-01-15T09:00:00Z'));
expect(model.lastRunAt, DateTime.parse('2024-01-14T09:00:00Z'));
expect(model.createdAt, DateTime.parse('2024-01-01T00:00:00Z'));
expect(model.updatedAt, DateTime.parse('2024-01-14T12:00:00Z'));
});
test('fromJson throws for missing required date fields', () {
final json = <String, dynamic>{
'id': 'job-123',
'owner_id': 'user-456',
'title': 'Test',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'UTC',
'status': 'ACTIVE',
'is_system': false,
'config': null,
};
expect(
() => AutomationJobModel.fromJson(json),
throwsA(isA<FormatException>()),
);
});
});
group('AutomationJobConfigPatchModel', () {
test('toJson only includes non-null fields', () {
final model = AutomationJobConfigPatchModel(
inputTemplate: 'Updated template',
);
final json = model.toJson();
expect(json.containsKey('input_template'), true);
expect(json.containsKey('enabled_tools'), false);
expect(json.containsKey('context'), false);
expect(json['input_template'], 'Updated template');
});
test('toJson includes all fields when set', () {
final model = AutomationJobConfigPatchModel(
inputTemplate: 'Template',
enabledTools: ['tool1', 'tool2'],
context: MessageContextConfigModel(
source: 'messages',
windowMode: 'week',
windowCount: 3,
),
);
final json = model.toJson();
expect(json['input_template'], 'Template');
expect(json['enabled_tools'], ['tool1', 'tool2']);
expect(json['context'], {
'source': 'messages',
'window_mode': 'week',
'window_count': 3,
});
});
});
group('AutomationJobUpdateRequest', () {
test('toJson only includes non-null fields', () {
final request = AutomationJobUpdateRequest(
title: 'Updated Title',
status: 'INACTIVE',
);
final json = request.toJson();
expect(json.containsKey('title'), true);
expect(json.containsKey('status'), true);
expect(json.containsKey('schedule_type'), false);
expect(json.containsKey('run_at'), false);
expect(json['title'], 'Updated Title');
expect(json['status'], 'INACTIVE');
});
test('toJson includes patch config with only non-null fields', () {
final request = AutomationJobUpdateRequest(
config: AutomationJobConfigPatchModel(inputTemplate: 'New template'),
);
final json = request.toJson();
expect(json.containsKey('config'), true);
final configJson = json['config'] as Map<String, dynamic>;
expect(configJson.containsKey('input_template'), true);
expect(configJson.containsKey('enabled_tools'), false);
expect(configJson.containsKey('context'), false);
expect(model.isDaily, isTrue);
expect(model.isWeekly, isFalse);
});
});
group('AutomationJobCreateRequest', () {
test('toJson serializes correctly', () {
test('toJson serializes schedule under config', () {
final request = AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'DAILY',
runAt: '10:00:00',
timezone: 'UTC',
status: 'ACTIVE',
config: AutomationJobConfigModel(
@@ -224,74 +84,47 @@ void main() {
windowMode: 'day',
windowCount: 2,
),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 10, minute: 0),
),
),
);
final json = request.toJson();
expect(json['title'], 'New Job');
expect(json['schedule_type'], 'DAILY');
expect(json['run_at'], '10:00:00');
expect(json['timezone'], 'UTC');
expect(json['status'], 'ACTIVE');
expect(json['config'], {
'input_template': 'Hello',
'enabled_tools': ['tool1'],
'context': {
'source': 'latest_chat',
'window_mode': 'day',
'window_count': 2,
},
expect((json['config'] as Map<String, dynamic>)['schedule'], {
'type': 'daily',
'run_at': {'hour': 10, 'minute': 0},
});
expect(json.containsKey('run_at'), isFalse);
expect(json.containsKey('schedule_type'), isFalse);
});
});
group('AutomationJobUpdateRequest', () {
test('toJson includes schedule patch in config', () {
final request = AutomationJobUpdateRequest(
config: AutomationJobConfigPatchModel(
schedule: ScheduleConfigModel(
type: 'weekly',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
weekdays: [2, 4],
),
),
);
final json = request.toJson();
final configJson = json['config'] as Map<String, dynamic>;
expect(configJson['schedule'], {
'type': 'weekly',
'run_at': {'hour': 8, 'minute': 0},
'weekdays': [2, 4],
});
});
});
group('AutomationJobListResponse', () {
test('fromJson parses items correctly', () {
final json = {
'items': [
{
'id': 'job-1',
'owner_id': 'user-1',
'title': 'Job 1',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'UTC',
'status': 'ACTIVE',
'is_system': false,
'config': null,
'next_run_at': '2024-01-15T09:00:00Z',
'created_at': '2024-01-01T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
},
{
'id': 'job-2',
'owner_id': 'user-1',
'title': 'Job 2',
'schedule_type': 'HOURLY',
'run_at': '00:00:00',
'timezone': 'UTC',
'status': 'INACTIVE',
'is_system': false,
'config': null,
'next_run_at': '2024-01-15T10:00:00Z',
'created_at': '2024-01-02T00:00:00Z',
'updated_at': '2024-01-14T12:00:00Z',
},
],
};
final response = AutomationJobListResponse.fromJson(json);
expect(response.items.length, 2);
expect(response.items[0].id, 'job-1');
expect(response.items[1].id, 'job-2');
});
test('fromJson returns empty list for null items', () {
final response = AutomationJobListResponse.fromJson(null);
expect(response.items, isEmpty);
});
});
}
@@ -18,8 +18,6 @@ void main() {
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
@@ -31,6 +29,10 @@ void main() {
windowMode: 'day',
windowCount: 2,
),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
@@ -21,8 +21,6 @@ void main() {
id: '1',
ownerId: 'owner1',
title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC',
status: 'ACTIVE',
isSystem: false,
@@ -34,6 +32,10 @@ void main() {
windowMode: 'day',
windowCount: 2,
),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
),
nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
@@ -173,8 +175,6 @@ void main() {
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
@@ -185,6 +185,10 @@ void main() {
windowMode: 'day',
windowCount: 2,
),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
),
),
),
@@ -210,8 +214,6 @@ void main() {
act: (c) => c.createJob(
AutomationJobCreateRequest(
title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai',
status: 'active',
config: AutomationJobConfigModel(
@@ -222,6 +224,10 @@ void main() {
windowMode: 'day',
windowCount: 2,
),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
),
),
),
@@ -0,0 +1,111 @@
"""move automation schedule fields into config
Revision ID: 202603240001
Revises: 202603230003
Create Date: 2026-03-24 18:20:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "202603240001"
down_revision: Union[str, Sequence[str], None] = "202603230003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
UPDATE public.automation_jobs aj
SET config = jsonb_set(
coalesce(aj.config, '{}'::jsonb),
'{schedule}',
jsonb_build_object(
'type', coalesce(aj.config->'schedule'->>'type', aj.schedule_type),
'run_at', jsonb_build_object(
'hour', extract(hour from (aj.run_at AT TIME ZONE aj.timezone))::int,
'minute', extract(minute from (aj.run_at AT TIME ZONE aj.timezone))::int
)
) || CASE
WHEN coalesce(aj.config->'schedule'->>'type', aj.schedule_type) = 'weekly'
THEN jsonb_build_object(
'weekdays',
coalesce(
aj.config->'schedule'->'weekdays',
jsonb_build_array(
extract(isodow from (aj.run_at AT TIME ZONE aj.timezone))::int
)
)
)
ELSE '{}'::jsonb
END,
true
)
"""
)
op.execute(
"""
ALTER TABLE public.automation_jobs
DROP CONSTRAINT IF EXISTS chk_automation_job_schedule_type
"""
)
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("automation_jobs")}
if "schedule_type" in columns:
op.drop_column("automation_jobs", "schedule_type")
if "run_at" in columns:
op.drop_column("automation_jobs", "run_at")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("automation_jobs")}
if "schedule_type" not in columns:
op.add_column(
"automation_jobs",
sa.Column("schedule_type", sa.String(length=20), nullable=True),
)
if "run_at" not in columns:
op.add_column(
"automation_jobs",
sa.Column("run_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"""
UPDATE public.automation_jobs aj
SET schedule_type = coalesce(aj.config->'schedule'->>'type', 'daily'),
run_at = (
date_trunc('day', timezone(aj.timezone, now()))
+ make_interval(
hours => coalesce((aj.config->'schedule'->'run_at'->>'hour')::int, 8),
mins => coalesce((aj.config->'schedule'->'run_at'->>'minute')::int, 0)
)
) AT TIME ZONE aj.timezone
"""
)
op.execute(
"""
ALTER TABLE public.automation_jobs
ALTER COLUMN schedule_type SET NOT NULL,
ALTER COLUMN run_at SET NOT NULL
"""
)
op.execute(
"""
ALTER TABLE public.automation_jobs
ADD CONSTRAINT chk_automation_job_schedule_type
CHECK (schedule_type IN ('daily', 'weekly'))
"""
)
@@ -319,7 +319,7 @@ class AgentScopeRunner:
content = user_blocks
user_msg = Msg(name="user", role="user", content=content)
return [user_msg, *context_messages]
return [*context_messages, user_msg]
async def _run_worker_stage(
self,
@@ -1,11 +1,23 @@
input_template: |
你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具
步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。
步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。
步骤3:对新增或变化的信息,调用 memory_write 执行写入
步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串
步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写
输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。
你正在执行一次“自动化记忆回顾与整理”任务
任务目标:
1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化
2) 对已经失效、被否定或明显过期的信息执行遗忘
3) 对新增且有证据支持的信息执行写入
4) 严禁编造;没有证据就不要写入。
5) 只更新最小必要字段,避免过度覆盖。
输出要求:
- 必须使用以下固定格式输出;每一行都要有:
【记忆回顾】<一句人性化总结,说明今天主要发生了什么>
【新增记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【遗忘记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明“可继续观察”>
表达风格:
- 语言自然、温和、可读,像助理在做每日回顾。
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
enabled_tools:
- memory.write
- memory.forget
@@ -18,3 +30,4 @@ schedule:
run_at:
hour: 8
minute: 0
weekdays: null
-8
View File
@@ -45,14 +45,6 @@ class AutomationJob(TimestampMixin, SoftDeleteMixin, Base):
nullable=False,
default=dict,
)
schedule_type: Mapped[ScheduleType] = mapped_column(
String(20),
nullable=False,
)
run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
next_run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
+16 -5
View File
@@ -5,7 +5,7 @@ from enum import Enum
from uuid import UUID
from core.agentscope.tools.tool_config import AgentTool
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
@@ -40,6 +40,21 @@ class ScheduleConfig(BaseModel):
type: ScheduleType
run_at: ScheduleRunAt
weekdays: list[int] | None = None
@model_validator(mode="after")
def validate_weekdays(self) -> "ScheduleConfig":
if self.type == ScheduleType.WEEKLY:
if not self.weekdays:
raise ValueError("weekdays is required when schedule type is weekly")
invalid = [day for day in self.weekdays if day < 1 or day > 7]
if invalid:
raise ValueError("weekdays must be within 1-7")
deduped = sorted(set(self.weekdays))
self.weekdays = deduped
else:
self.weekdays = None
return self
class RuntimeConfig(BaseModel):
@@ -66,8 +81,6 @@ class AutomationJob(BaseModel):
bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64)
title: str = Field(..., min_length=1, max_length=255)
config: AutomationJobConfig
schedule_type: ScheduleType
run_at: datetime
next_run_at: datetime
timezone: str = Field(default="UTC", min_length=1, max_length=50)
last_run_at: datetime | None = None
@@ -84,8 +97,6 @@ class AutomationJob(BaseModel):
bootstrap_key=obj.bootstrap_key,
title=obj.title,
config=AutomationJobConfig.model_validate(obj.config or {}),
schedule_type=obj.schedule_type,
run_at=obj.run_at,
next_run_at=obj.next_run_at,
timezone=obj.timezone,
last_run_at=obj.last_run_at,
@@ -7,12 +7,7 @@ from typing import Any
import yaml
from core.agentscope.tools.tool_config import AgentTool
from models.automation_jobs import ScheduleType
from schemas.automation import (
AutomationJobConfig,
MessageContextConfig,
)
from schemas.automation import AutomationJobConfig
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -37,18 +32,4 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi
loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"invalid automation config format: {path}")
config = AutomationJobConfig.model_validate(loaded)
if config_name == "memory_extraction":
if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]:
raise ValueError(
"memory_extraction enabled_tools must be [memory.write, memory.forget]"
)
if config.context != MessageContextConfig(window_count=2):
raise ValueError(
"memory_extraction context must be latest_chat/day with window_count=2"
)
if config.schedule is None:
raise ValueError("memory_extraction schedule must be configured")
if config.schedule.type != ScheduleType.DAILY:
raise ValueError("memory_extraction schedule type must be daily")
return config
return AutomationJobConfig.model_validate(loaded)
+37 -34
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, time, timedelta
from typing import Protocol
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -13,7 +13,7 @@ from core.logging import get_logger
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from models.memories import MemoryType
from models.profile import Profile
from schemas.automation import AutomationJobConfig
from schemas.automation import AutomationJobConfig, ScheduleConfig
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
from schemas.user.context import parse_profile_settings
from v1.auth.automation_static_config import load_static_automation_job_config
@@ -44,9 +44,7 @@ class RegistrationBootstrapRepository:
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool:
stmt = (
insert(AutomationJob)
@@ -56,8 +54,6 @@ class RegistrationBootstrapRepository:
bootstrap_key=bootstrap_key,
title=title,
config=config.model_dump(mode="json"),
schedule_type=schedule_type,
run_at=run_at,
next_run_at=next_run_at,
timezone=timezone_name,
status=AutomationJobStatus.ACTIVE,
@@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool: ...
async def upsert_initial_memory(
@@ -123,35 +117,48 @@ class SessionLike(Protocol):
async def rollback(self) -> None: ...
def compute_next_local_time_utc(
def compute_first_run_at_utc(
*,
now_utc: datetime,
timezone_name: str,
local_hour: int,
local_minute: int,
schedule_type: ScheduleType,
) -> tuple[datetime, datetime]:
schedule: ScheduleConfig,
) -> datetime:
try:
timezone_obj = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
timezone_obj = ZoneInfo("UTC")
local_now = now_utc.astimezone(timezone_obj)
today_run_local = local_now.replace(
hour=local_hour,
minute=local_minute,
second=0,
microsecond=0,
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=timezone_obj,
)
run_local = (
today_run_local
if local_now <= today_run_local
else today_run_local + timedelta(days=1)
)
if schedule_type == ScheduleType.WEEKLY:
next_local = run_local + timedelta(weeks=1)
else:
next_local = run_local + timedelta(days=1)
return run_local.astimezone(UTC), next_local.astimezone(UTC)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(UTC)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(UTC)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(UTC)
class RegistrationAutomationBootstrapService:
@@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService:
raise ValueError(
f"bootstrap job {bootstrap_key} has no schedule configured"
)
run_at, next_run_at = compute_next_local_time_utc(
next_run_at = compute_first_run_at_utc(
now_utc=datetime.now(UTC),
timezone_name=timezone_name,
local_hour=schedule.run_at.hour,
local_minute=schedule.run_at.minute,
schedule_type=schedule.type,
schedule=schedule,
)
inserted = (
await self._repository.insert_bootstrap_automation_job_if_absent(
@@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService:
title=str(definition["title"]),
config=job_config,
timezone_name=timezone_name,
run_at=run_at,
next_run_at=next_run_at,
schedule_type=schedule.type,
)
)
inserted_any = inserted_any or inserted
+64 -73
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession, SessionType
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from schemas.automation import AutomationJobConfig, ScheduleConfig
if TYPE_CHECKING:
from v1.automation_jobs.schemas import (
@@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
except ZoneInfoNotFoundError:
return ZoneInfo("UTC")
def _compute_initial_next_run_at(
def _compute_next_run_at(
self,
*,
run_at: time,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
tz = self._resolve_timezone(timezone_str)
local_now = now_utc.astimezone(tz)
run_at_local = datetime.combine(local_now.date(), run_at, tz)
if run_at_local.tzinfo is None:
run_at_local = run_at_local.replace(tzinfo=tz)
next_run_at = run_at_local
if next_run_at <= local_now:
if schedule_type == ScheduleType.DAILY:
next_run_at = next_run_at + timedelta(days=1)
else:
next_run_at = next_run_at + timedelta(weeks=1)
return next_run_at.astimezone(timezone.utc)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
async def create(
self,
@@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobCreateRequest,
) -> AutomationJob:
now_utc = datetime.now(tz=timezone.utc)
timezone_obj = self._resolve_timezone(data.timezone)
local_now = now_utc.astimezone(timezone_obj)
date_ref = local_now.date()
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
run_at_datetime = local_dt.astimezone(timezone.utc)
next_run_at = self._compute_initial_next_run_at(
run_at=data.run_at,
schedule = data.config.schedule
if schedule is None:
raise ValueError("config.schedule is required")
next_run_at = self._compute_next_run_at(
schedule=schedule,
timezone_str=data.timezone,
now_utc=now_utc,
schedule_type=data.schedule_type,
)
new_job = AutomationJob(
@@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
created_by=owner_id,
bootstrap_key=None,
title=data.title,
schedule_type=data.schedule_type,
run_at=run_at_datetime,
timezone=data.timezone,
status=data.status,
config=data.config.model_dump(mode="json"),
@@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobUpdateRequest,
) -> AutomationJob | None:
update_values: dict[str, object] = {}
existing_job: AutomationJob | None = None
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
if data.title is not None:
update_values["title"] = data.title
if data.schedule_type is not None:
update_values["schedule_type"] = data.schedule_type
should_recompute_schedule = (
data.run_at is not None
or data.schedule_type is not None
or data.timezone is not None
)
if should_recompute_schedule:
now_utc = datetime.now(tz=timezone.utc)
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
effective_timezone = data.timezone or existing_job.timezone
effective_timezone_obj = self._resolve_timezone(effective_timezone)
effective_schedule_type = data.schedule_type or existing_job.schedule_type
if data.run_at is not None:
effective_run_at = data.run_at
else:
existing_timezone_obj = self._resolve_timezone(existing_job.timezone)
effective_run_at = (
existing_job.run_at.astimezone(existing_timezone_obj)
.time()
.replace(microsecond=0)
)
local_now = now_utc.astimezone(effective_timezone_obj)
local_dt = datetime.combine(
local_now.date(),
effective_run_at,
effective_timezone_obj,
)
update_values["run_at"] = local_dt.astimezone(timezone.utc)
update_values["next_run_at"] = self._compute_initial_next_run_at(
run_at=effective_run_at,
timezone_str=effective_timezone,
now_utc=now_utc,
schedule_type=effective_schedule_type,
)
if data.timezone is not None:
update_values["timezone"] = data.timezone
if data.status is not None:
update_values["status"] = data.status
merged_config_raw: dict[str, object] = dict(existing_job.config or {})
if data.config is not None:
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
merged_config = {
**existing_job.config,
merged_config_raw = {
**merged_config_raw,
**data.config.model_dump(mode="json", exclude_unset=True),
}
update_values["config"] = merged_config
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
update_values["config"] = normalized_config.model_dump(mode="json")
else:
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
schedule_changed = data.config is not None and (
"schedule" in data.config.model_dump(mode="json", exclude_unset=True)
)
if data.timezone is not None or schedule_changed:
if normalized_config.schedule is None:
raise ValueError("config.schedule is required")
effective_timezone = data.timezone or existing_job.timezone
update_values["next_run_at"] = self._compute_next_run_at(
schedule=normalized_config.schedule,
timezone_str=effective_timezone,
now_utc=datetime.now(tz=timezone.utc),
)
if not update_values:
return await self.get_by_id(job_id)
return existing_job
return await self.update_by_id(job_id, update_values)
+9 -11
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime
from typing import Self
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
from models.automation_jobs import AutomationJobStatus
from schemas.automation import AutomationJobConfig
@@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel):
owner_id: UUID
bootstrap_key: str | None = None
title: str
schedule_type: ScheduleType
run_at: time
timezone: str
status: AutomationJobStatus
is_system: bool
@@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel):
owner_id=obj.owner_id,
bootstrap_key=obj.bootstrap_key,
title=obj.title,
schedule_type=obj.schedule_type,
run_at=obj.run_at.time(),
timezone=obj.timezone,
status=obj.status,
is_system=obj.bootstrap_key is not None,
@@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255)
schedule_type: ScheduleType
run_at: time = Field(..., description="Local time in HH:MM:SS format")
timezone: str = Field(..., min_length=1, max_length=50)
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
config: AutomationJobConfig
@model_validator(mode="after")
def validate_schedule_required(self) -> "AutomationJobCreateRequest":
if self.config.schedule is None:
raise ValueError("config.schedule is required")
return self
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
@@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255)
schedule_type: ScheduleType | None = None
run_at: time | None = None
timezone: str | None = Field(None, min_length=1, max_length=50)
status: AutomationJobStatus | None = None
config: AutomationJobConfig | None = None
+50 -10
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta, timezone
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException, status
from models.automation_jobs import ScheduleType
@@ -11,6 +12,7 @@ from schemas.automation import (
AutomationJob as AutomationJobSchema,
MessageContextConfig,
RuntimeConfig,
ScheduleConfig,
)
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
@@ -68,15 +70,53 @@ class DispatchFn(Protocol):
def _compute_next_run_at(
*,
current_next_run_at: datetime,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7)
next_run_at = current_next_run_at
while next_run_at <= now_utc:
next_run_at = next_run_at + delta
return next_run_at
try:
tz = ZoneInfo(timezone_str)
except ZoneInfoNotFoundError:
tz = ZoneInfo("UTC")
local_now = now_utc.astimezone(tz)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
def _ensure_schedule(job: AutomationJobSchema) -> ScheduleConfig:
schedule = job.config.schedule
if schedule is None:
raise ValueError(f"job {job.id} config.schedule is missing")
return schedule
@dataclass(slots=True)
@@ -129,9 +169,9 @@ class AutomationJobsService:
await self._repository.update_job_schedule(
job_id=job.id,
next_run_at=_compute_next_run_at(
current_next_run_at=job.next_run_at,
schedule=_ensure_schedule(job),
timezone_str=job.timezone,
now_utc=now_utc,
schedule_type=job.schedule_type,
),
last_run_at=now_utc,
)
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, time, timezone
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
@@ -29,13 +29,24 @@ def _make_job_response(
id=job_id or uuid4(),
owner_id=owner_id or uuid4(),
title=overrides.get("title", "Test Job"),
schedule_type=overrides.get("schedule_type", "daily"),
run_at=overrides.get("run_at", time(9, 0, 0)),
timezone=overrides.get("timezone", "Asia/Shanghai"),
status=overrides.get("status", "active"),
is_system=overrides.get("is_system", False),
config=overrides.get(
"config", {"input_template": "Hello", "enabled_tools": [], "context": {}}
"config",
{
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
),
next_run_at=overrides.get("next_run_at", now),
created_at=overrides.get("created_at", now),
@@ -104,13 +115,19 @@ def test_create_automation_job_requires_auth() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -140,14 +157,20 @@ def test_create_automation_job_succeeds() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"status": "active",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -178,14 +201,20 @@ def test_create_automation_job_respects_limit() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"status": "active",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -204,7 +233,7 @@ def test_get_automation_job_requires_auth() -> None:
def test_get_automation_job_returns_job() -> None:
user_id = uuid4()
job_id = uuid4()
job = _make_job_response(id=job_id, owner_id=user_id)
job = _make_job_response(job_id=job_id, owner_id=user_id)
captured_job_id = job_id
captured_owner_id = user_id
@@ -266,7 +295,11 @@ def test_update_automation_job_requires_auth() -> None:
def test_update_automation_job_succeeds() -> None:
user_id = uuid4()
job_id = uuid4()
updated_job = _make_job_response(id=job_id, owner_id=user_id, title="Updated Title")
updated_job = _make_job_response(
job_id=job_id,
owner_id=user_id,
title="Updated Title",
)
class FakeService:
async def update(
@@ -114,6 +114,28 @@ def test_build_router_messages_skips_injection_when_context_last_is_user() -> No
assert msg.content == existing_context[i].content
def test_build_router_messages_appends_user_input_to_context_tail() -> None:
runner = AgentScopeRunner()
run_input = _run_input()
from agentscope.message import Msg
existing_context = [
Msg(name="assistant", role="assistant", content="上一轮回复"),
Msg(name="tool", role="assistant", content="工具结果"),
]
messages = runner._build_router_messages(
context_messages=existing_context,
run_input=run_input,
)
assert len(messages) == len(existing_context) + 1
assert messages[-1].role == "user"
assert messages[-1].content == "hello"
assert messages[0].content == "上一轮回复"
def test_build_model_omits_none_generate_kwargs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -8,6 +8,8 @@ import pytest
from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType
from schemas.automation import (
RuntimeConfig,
ScheduleConfig,
ScheduleRunAt,
)
from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at
@@ -50,7 +52,6 @@ def _make_orm_job(
*,
job_id: UUID | None = None,
owner_id: UUID | None = None,
schedule_type: ScheduleType = ScheduleType.DAILY,
next_run_at: datetime | None = None,
) -> OrmAutomationJob:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
@@ -66,9 +67,14 @@ def _make_orm_job(
"window_count": 2,
},
"input_template": "auto input: {date}",
"schedule": {
"type": "daily",
"run_at": {
"hour": 8,
"minute": 0,
},
},
},
schedule_type=schedule_type,
run_at=now - timedelta(hours=1),
next_run_at=next_run_at or now - timedelta(minutes=1),
timezone="UTC",
last_run_at=None,
@@ -108,12 +114,14 @@ async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None
def test_compute_next_run_at_daily() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
current = datetime(2026, 3, 19, 11, 0, tzinfo=timezone.utc)
computed = _compute_next_run_at(
current_next_run_at=current,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=11, minute=0),
),
timezone_str="UTC",
now_utc=now,
schedule_type=ScheduleType.DAILY,
)
assert computed == datetime(2026, 3, 20, 11, 0, tzinfo=timezone.utc)
@@ -121,12 +129,15 @@ def test_compute_next_run_at_daily() -> None:
def test_compute_next_run_at_weekly() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
current = datetime(2026, 3, 10, 11, 0, tzinfo=timezone.utc)
computed = _compute_next_run_at(
current_next_run_at=current,
schedule=ScheduleConfig(
type=ScheduleType.WEEKLY,
run_at=ScheduleRunAt(hour=11, minute=0),
weekdays=[2],
),
timezone_str="UTC",
now_utc=now,
schedule_type=ScheduleType.WEEKLY,
)
assert computed == datetime(2026, 3, 24, 11, 0, tzinfo=timezone.utc)
@@ -13,7 +13,7 @@ def test_memory_automation_static_config_contract() -> None:
"memory.forget",
]
assert config.input_template is not None
assert "提取" in config.input_template
assert "回顾" in config.input_template
assert "遗忘" in config.input_template
assert config.schedule is not None
assert config.schedule.type.value == "daily"
@@ -8,38 +8,39 @@ import pytest
from models.automation_jobs import ScheduleType
from v1.auth.registration_bootstrap import (
compute_next_local_time_utc,
compute_first_run_at_utc,
)
from schemas.automation import ScheduleConfig, ScheduleRunAt
def test_compute_next_local_time_utc_from_asia_shanghai() -> None:
def test_compute_first_run_at_utc_from_asia_shanghai() -> None:
now_utc = datetime(2026, 3, 23, 0, 30, tzinfo=timezone.utc)
run_at, next_run_at = compute_next_local_time_utc(
first_run_at = compute_first_run_at_utc(
now_utc=now_utc,
timezone_name="Asia/Shanghai",
local_hour=8,
local_minute=0,
schedule_type=ScheduleType.DAILY,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=8, minute=0),
),
)
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc)
assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
def test_compute_next_local_time_utc_rolls_to_next_day_when_passed() -> None:
def test_compute_first_run_at_utc_rolls_to_next_day_when_passed() -> None:
now_utc = datetime(2026, 3, 23, 2, 30, tzinfo=timezone.utc)
run_at, next_run_at = compute_next_local_time_utc(
first_run_at = compute_first_run_at_utc(
now_utc=now_utc,
timezone_name="Asia/Shanghai",
local_hour=8,
local_minute=0,
schedule_type=ScheduleType.DAILY,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=8, minute=0),
),
)
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc)
assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
@@ -1,21 +1,22 @@
from datetime import datetime, time, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from models.automation_jobs import AutomationJobStatus, ScheduleType
from v1.automation_jobs.repository import AutomationJobsRepository
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
)
from schemas.automation import (
AgentTool,
AutomationJobConfig,
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
from v1.automation_jobs.repository import AutomationJobsRepository
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
)
@@ -28,14 +29,16 @@ def _make_config() -> AutomationJobConfig:
window_mode=ContextWindowMode.DAY,
window_count=2,
),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
)
def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest(
title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE,
config=_make_config(),
@@ -57,9 +60,6 @@ async def test_list_by_owner_returns_jobs() -> None:
assert result == [job_one, job_two]
session.execute.assert_awaited_once()
call_args = session.execute.call_args
stmt = call_args[0][0]
assert "owner_id" in str(stmt)
@pytest.mark.asyncio
@@ -74,16 +74,10 @@ async def test_count_user_jobs_counts_non_bootstrap_jobs() -> None:
result = await repository.count_user_jobs(owner_id)
assert result == 3
session.execute.assert_awaited_once()
call_args = session.execute.call_args
stmt = call_args[0][0]
stmt_str = str(stmt)
assert "bootstrap_key" in stmt_str
assert "IS NULL" in stmt_str or "is_(None)" in stmt_str.lower()
@pytest.mark.asyncio
async def test_create_sets_bootstrap_key_to_none() -> None:
async def test_create_sets_fields_and_next_run_at() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
@@ -93,67 +87,13 @@ async def test_create_sets_bootstrap_key_to_none() -> None:
await repository.create(owner_id, data)
session.add.assert_called_once()
call_args = session.add.call_args[0][0]
assert call_args.bootstrap_key is None
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_sets_correct_fields() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
owner_id = uuid4()
data = _make_create_request()
await repository.create(owner_id, data)
call_args = session.add.call_args[0][0]
assert call_args.owner_id == owner_id
assert call_args.bootstrap_key is None
assert call_args.title == data.title
assert call_args.schedule_type == data.schedule_type
assert call_args.timezone == data.timezone
assert call_args.status == data.status
@pytest.mark.asyncio
async def test_update_returns_updated_job() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.config = {"input_template": "Old"}
updated_job = MagicMock()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = updated_job
session.execute.return_value = execute_result
data = AutomationJobUpdateRequest(title="Updated Title")
result = await repository.update(job_id, data)
assert result is updated_job
session.flush.assert_awaited()
@pytest.mark.asyncio
async def test_update_merges_config() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.config = {"input_template": "Old", "enabled_tools": []}
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = existing_job
session.execute.return_value = execute_result
data = AutomationJobUpdateRequest(
config={"input_template": "New", "context": {"source": "latest_chat"}}
)
await repository.update(job_id, data)
session.flush.assert_awaited()
assert call_args.next_run_at is not None
@pytest.mark.asyncio
@@ -161,9 +101,8 @@ async def test_update_returns_none_when_job_not_found() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = None
session.execute.return_value = execute_result
repository.get_by_id = AsyncMock(return_value=None)
data = AutomationJobUpdateRequest(title="Updated Title")
result = await repository.update(job_id, data)
@@ -171,6 +110,50 @@ async def test_update_returns_none_when_job_not_found() -> None:
assert result is None
@pytest.mark.asyncio
async def test_update_merges_config_and_recomputes_next_run() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.timezone = "UTC"
existing_job.config = {
"input_template": "Old",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 8, "minute": 0},
},
}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(
config=AutomationJobConfig(
enabled_tools=[AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET],
schedule=ScheduleConfig(
type=ScheduleType.WEEKLY,
run_at=ScheduleRunAt(hour=10, minute=30),
weekdays=[2, 5],
),
),
)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert "config" in update_values
assert "next_run_at" in update_values
enabled_tools = update_values["config"]["enabled_tools"]
assert isinstance(enabled_tools[0], str)
@pytest.mark.asyncio
async def test_soft_delete_calls_soft_delete_by_id() -> None:
session = AsyncMock()
@@ -197,91 +180,3 @@ async def test_list_due_jobs_filters_by_active_status() -> None:
await repository.list_due_jobs(now_utc=MagicMock(), limit=10)
session.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_stores_run_at_as_timezone_aware() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
owner_id = uuid4()
data = _make_create_request()
await repository.create(owner_id, data)
call_args = session.add.call_args[0][0]
assert call_args.run_at.tzinfo is not None, "run_at should be timezone-aware"
@pytest.mark.asyncio
async def test_update_run_at_with_timezone_none_uses_existing_timezone() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "America/New_York"
existing_job.config = {}
existing_job.run_at = None
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = existing_job
session.execute.return_value = execute_result
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(run_at=time(14, 30, 0))
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert "run_at" in update_values
assert "next_run_at" in update_values
@pytest.mark.asyncio
async def test_update_schedule_type_recomputes_next_run_at() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "UTC"
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
existing_job.config = {}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(schedule_type=ScheduleType.WEEKLY)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert update_values["schedule_type"] == ScheduleType.WEEKLY
assert "run_at" in update_values
assert "next_run_at" in update_values
@pytest.mark.asyncio
async def test_update_config_serializes_enum_values_to_json() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "UTC"
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
existing_job.config = {"input_template": "Old"}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(
config={"enabled_tools": [AgentTool.MEMORY_WRITE]},
)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
enabled_tools = update_values["config"]["enabled_tools"]
assert isinstance(enabled_tools[0], str)
@@ -1,246 +1,107 @@
import pytest
from datetime import datetime
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from schemas.automation import AgentTool, AutomationJobConfig
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
AutomationJobResponse,
AutomationJobUpdateRequest,
)
from schemas.automation import AgentTool, AutomationJobConfig
class TestIsSystemProperty:
def test_is_system_true_when_bootstrap_key_present(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "memory_extraction"
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is True
def test_is_system_false_when_bootstrap_key_none(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is False
def _mock_orm_job() -> MagicMock:
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "memory_extraction"
mock_orm_job.title = "Test Job"
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write", "memory.forget"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 8, "minute": 0},
},
}
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
return mock_orm_job
class TestFromOrm:
def test_run_at_converted_from_datetime_to_time(self):
run_at_datetime = datetime(2024, 6, 15, 14, 30, 0)
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = run_at_datetime
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.run_at == run_at_datetime.time()
def test_config_deserialized(self):
config = {
"input_template": "Test template",
"enabled_tools": [AgentTool.MEMORY_WRITE],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 5,
},
}
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = config
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.config.input_template == "Test template"
assert resp.config.enabled_tools == [AgentTool.MEMORY_WRITE]
assert resp.config.context.window_count == 5
def test_is_system_derived_from_bootstrap_key(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "system_bootstrap"
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "UTC"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is True
assert resp.bootstrap_key == "system_bootstrap"
def test_response_is_system_true_when_bootstrap_key_present() -> None:
resp = AutomationJobResponse.from_orm(_mock_orm_job())
assert resp.is_system is True
class TestTimezoneValidation:
def test_valid_timezone(self):
request = AutomationJobCreateRequest.model_validate(
def test_response_parses_schedule_from_config() -> None:
resp = AutomationJobResponse.from_orm(_mock_orm_job())
assert resp.config.schedule is not None
assert resp.config.schedule.type.value == "daily"
assert resp.config.schedule.run_at.hour == 8
def test_create_request_requires_config_schedule() -> None:
with pytest.raises(ValidationError):
AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
},
}
)
assert request.timezone == "Asia/Shanghai"
def test_invalid_timezone(self):
with pytest.raises(ValidationError) as exc_info:
AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Invalid/Timezone",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
},
}
)
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
def test_update_valid_timezone(self):
request = AutomationJobUpdateRequest.model_validate(
{
"timezone": "America/New_York",
}
)
assert request.timezone == "America/New_York"
def test_update_invalid_timezone(self):
with pytest.raises(ValidationError) as exc_info:
AutomationJobUpdateRequest.model_validate(
{
"timezone": "Invalid/Timezone",
}
)
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
def test_update_none_timezone_allowed(self):
request = AutomationJobUpdateRequest.model_validate(
{
"timezone": None,
}
)
assert request.timezone is None
class TestAutomationJobConfigPatch:
def test_all_fields_optional(self):
patch = AutomationJobConfig.model_validate({})
assert patch.input_template is None
assert patch.enabled_tools is None
assert patch.context is None
def test_create_request_valid_timezone() -> None:
request = AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
}
)
assert request.timezone == "Asia/Shanghai"
def test_partial_input_template(self):
patch = AutomationJobConfig.model_validate(
{
"input_template": "Updated template",
}
)
assert patch.input_template == "Updated template"
assert patch.enabled_tools is None
assert patch.context is None
def test_extra_fields_forbidden(self):
with pytest.raises(ValidationError):
AutomationJobConfig.model_validate(
{
"input_template": "Test",
"unknown_field": "value",
}
)
def test_update_timezone_validation() -> None:
request = AutomationJobUpdateRequest.model_validate(
{"timezone": "America/New_York"}
)
assert request.timezone == "America/New_York"
with pytest.raises(ValidationError):
AutomationJobUpdateRequest.model_validate({"timezone": "Invalid/Timezone"})
def test_config_patch_still_allows_partial_payload() -> None:
patch = AutomationJobConfig.model_validate(
{"enabled_tools": [AgentTool.MEMORY_WRITE]}
)
assert patch.input_template is None
assert patch.enabled_tools == [AgentTool.MEMORY_WRITE]
@@ -1,6 +1,6 @@
from datetime import datetime, time, timezone
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
@@ -23,6 +23,8 @@ from schemas.automation import (
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
@@ -35,14 +37,16 @@ def _make_config() -> AutomationJobConfig:
window_mode=ContextWindowMode.DAY,
window_count=2,
),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
)
def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest(
title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE,
config=_make_config(),
@@ -50,18 +54,28 @@ def _make_create_request() -> AutomationJobCreateRequest:
def _make_job(
owner_id: MagicMock | None = None, bootstrap_key: str | None = None
owner_id: UUID | None = None, bootstrap_key: str | None = None
) -> MagicMock:
job = MagicMock()
job.id = uuid4()
job.owner_id = owner_id or uuid4()
job.bootstrap_key = bootstrap_key
job.title = "Test Job"
job.schedule_type = ScheduleType.DAILY
job.run_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
job.timezone = "Asia/Shanghai"
job.status = AutomationJobStatus.ACTIVE
job.config = {"input_template": "Hello"}
job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
}
job.next_run_at = datetime(2024, 1, 2, 9, 0, 0, tzinfo=timezone.utc)
job.last_run_at = None
job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
@@ -210,7 +224,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job_id, owner_id, AutomationJobUpdateRequest(title="New")
job_id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -225,7 +241,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -239,7 +257,9 @@ class TestUpdate:
with pytest.raises(SystemJobModificationForbidden):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
repository.update.assert_not_called()
@@ -257,12 +277,15 @@ class TestUpdate:
repository.update.return_value = updated_job
result = await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="Updated Title")
job.id,
owner_id,
AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"),
)
assert result.title == "Updated Title"
repository.update.assert_awaited_once_with(
job.id, AutomationJobUpdateRequest(title="Updated Title")
job.id,
AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"),
)
session.commit.assert_awaited_once()
@@ -278,7 +301,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -293,7 +318,9 @@ class TestUpdate:
with pytest.raises(HTTPException) as exc:
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
assert exc.value.status_code == 503
+44
View File
@@ -0,0 +1,44 @@
# Automation Jobs Model Protocol
## Scope
This document defines the `automation_jobs` data contract used by backend API payloads,
scheduler computation, and Flutter settings pages.
## Canonical Fields
- `id`: UUID
- `owner_id`: UUID
- `title`: string
- `config`: object
- `input_template`: string
- `enabled_tools`: string[]
- `context`: object
- `source`: `latest_chat`
- `window_mode`: `day | number`
- `window_count`: int
- `schedule`: object
- `type`: `daily | weekly`
- `run_at`: object
- `hour`: int (0-23)
- `minute`: int (0-59)
- `weekdays`: int[] (only for weekly; Monday=1 ... Sunday=7)
- `timezone`: IANA timezone string
- `next_run_at`: timestamptz (UTC), scheduler due cursor
- `last_run_at`: timestamptz | null
- `status`: `active | disabled`
- `created_at`: timestamptz
- `updated_at`: timestamptz
## Scheduling Semantics
- Scheduler scans only by `next_run_at <= now_utc`.
- Scheduler calculates subsequent `next_run_at` from `config.schedule + timezone`.
- `run_at` and `schedule_type` top-level columns are deprecated and removed.
## Compatibility Strategy
- Strategy: **migration-required change**.
- Existing rows must be migrated by backfilling `config.schedule` from legacy
`run_at/schedule_type` before dropping those columns.
- Clients must send schedule data through `config.schedule` only.