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? eventType;
String? eventId; String? eventId;
var hasBoundExpectedRun = false; var hasBoundExpectedRun = false;
var hasSeenTerminalForRun = false;
final dataBuffer = StringBuffer(); final dataBuffer = StringBuffer();
final done = Completer<void>(); final done = Completer<void>();
late final StreamSubscription<String> subscription; late final StreamSubscription<String> subscription;
@@ -257,6 +258,7 @@ class AgUiService {
eventThreadId == null || eventThreadId == threadId; eventThreadId == null || eventThreadId == threadId;
if (isTerminalEvent && if (isTerminalEvent &&
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) { (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
hasSeenTerminalForRun = true;
stopStream(); stopStream();
return; return;
} }
@@ -291,6 +293,16 @@ class AgUiService {
stopStream(error: error, stackTrace: stackTrace); stopStream(error: error, stackTrace: stackTrace);
}, },
onDone: () { onDone: () {
if (streamToken != _activeStreamToken) {
stopStream();
return;
}
if (!hasSeenTerminalForRun) {
stopStream(
error: StateError('SSE closed before terminal event for run'),
);
return;
}
stopStream(); stopStream();
}, },
cancelOnError: false, cancelOnError: false,
@@ -1,7 +1,9 @@
enum AgentStage { execution, memory } enum AgentStage { routing, execution, memory }
AgentStage? stageFromStepName(String value) { AgentStage? stageFromStepName(String value) {
switch (value) { switch (value) {
case 'router':
return AgentStage.routing;
case 'worker': case 'worker':
return AgentStage.execution; return AgentStage.execution;
case 'memory': case 'memory':
@@ -13,6 +15,7 @@ AgentStage? stageFromStepName(String value) {
String stageLabel(AgentStage? stage) { String stageLabel(AgentStage? stage) {
return switch (stage) { return switch (stage) {
AgentStage.routing => '意图识别中',
AgentStage.execution => '任务执行中', AgentStage.execution => '任务执行中',
AgentStage.memory => '记忆提取中', AgentStage.memory => '记忆提取中',
null => '任务处理中', null => '任务处理中',
@@ -408,6 +408,11 @@ class ChatBloc extends Cubit<ChatState> {
uploadedAttachments: sendResult.uploadedAttachments, uploadedAttachments: sendResult.uploadedAttachments,
); );
} catch (error) { } catch (error) {
final sseClosedBeforeTerminal = _isSseClosedBeforeTerminalError(error);
var recoveredFromHistory = false;
if (sseClosedBeforeTerminal) {
recoveredFromHistory = await _recoverFromAbnormalSseClose();
}
_markAttachmentUploadDone(messageId); _markAttachmentUploadDone(messageId);
emit( emit(
state.copyWith( state.copyWith(
@@ -415,12 +420,45 @@ class ChatBloc extends Cubit<ChatState> {
isWaitingFirstToken: false, isWaitingFirstToken: false,
isStreaming: false, isStreaming: false,
isCancelling: 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({ void _syncUploadedAttachments({
required String messageId, required String messageId,
required List<UploadedAttachment> uploadedAttachments, required List<UploadedAttachment> uploadedAttachments,
@@ -484,10 +522,18 @@ class ChatBloc extends Cubit<ChatState> {
try { try {
final snapshot = await _service.loadHistory(); final snapshot = await _service.loadHistory();
final newItems = _convertHistoryMessages(snapshot.messages); 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( emit(
state.copyWith( state.copyWith(
items: newItems, items: merged,
oldestLoadedDate: oldestDate, oldestLoadedDate: oldestDate,
hasEarlierHistory: snapshot.hasMore, 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 { class AutomationJobConfigModel {
final String inputTemplate; final String inputTemplate;
final List<String> enabledTools; final List<String> enabledTools;
final MessageContextConfigModel context; final MessageContextConfigModel context;
final ScheduleConfigModel schedule;
AutomationJobConfigModel({ AutomationJobConfigModel({
required this.inputTemplate, required this.inputTemplate,
required this.enabledTools, required this.enabledTools,
required this.context, required this.context,
required this.schedule,
}); });
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) { factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
@@ -109,6 +189,7 @@ class AutomationJobConfigModel {
inputTemplate: '', inputTemplate: '',
enabledTools: const [], enabledTools: const [],
context: MessageContextConfigModel.fromJson(null), context: MessageContextConfigModel.fromJson(null),
schedule: ScheduleConfigModel.fromJson(null),
); );
} }
return AutomationJobConfigModel( return AutomationJobConfigModel(
@@ -126,6 +207,11 @@ class AutomationJobConfigModel {
json['context'] as Map<String, dynamic>?, json['context'] as Map<String, dynamic>?,
) )
: MessageContextConfigModel.fromJson(null), : 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, 'input_template': inputTemplate,
'enabled_tools': enabledTools, 'enabled_tools': enabledTools,
'context': context.toJson(), 'context': context.toJson(),
'schedule': schedule.toJson(),
}; };
AutomationJobConfigModel copyWith({ AutomationJobConfigModel copyWith({
String? inputTemplate, String? inputTemplate,
List<String>? enabledTools, List<String>? enabledTools,
MessageContextConfigModel? context, MessageContextConfigModel? context,
ScheduleConfigModel? schedule,
}) { }) {
return AutomationJobConfigModel( return AutomationJobConfigModel(
inputTemplate: inputTemplate ?? this.inputTemplate, inputTemplate: inputTemplate ?? this.inputTemplate,
enabledTools: enabledTools ?? this.enabledTools, enabledTools: enabledTools ?? this.enabledTools,
context: context ?? this.context, context: context ?? this.context,
schedule: schedule ?? this.schedule,
); );
} }
} }
@@ -153,8 +242,6 @@ class AutomationJobModel {
final String ownerId; final String ownerId;
final String? bootstrapKey; final String? bootstrapKey;
final String title; final String title;
final String scheduleType;
final String runAt;
final String timezone; final String timezone;
final String status; final String status;
final bool isSystem; final bool isSystem;
@@ -169,8 +256,6 @@ class AutomationJobModel {
required this.ownerId, required this.ownerId,
this.bootstrapKey, this.bootstrapKey,
required this.title, required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone, required this.timezone,
required this.status, required this.status,
required this.isSystem, required this.isSystem,
@@ -193,16 +278,6 @@ class AutomationJobModel {
fallback: '', fallback: '',
), ),
title: _parseString(json['title'], field: 'title', 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( timezone: _parseString(
json['timezone'], json['timezone'],
field: 'timezone', field: 'timezone',
@@ -263,8 +338,6 @@ class AutomationJobModel {
'owner_id': ownerId, 'owner_id': ownerId,
'bootstrap_key': bootstrapKey, 'bootstrap_key': bootstrapKey,
'title': title, 'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone, 'timezone': timezone,
'status': status, 'status': status,
'is_system': isSystem, 'is_system': isSystem,
@@ -280,8 +353,6 @@ class AutomationJobModel {
String? ownerId, String? ownerId,
String? bootstrapKey, String? bootstrapKey,
String? title, String? title,
String? scheduleType,
String? runAt,
String? timezone, String? timezone,
String? status, String? status,
bool? isSystem, bool? isSystem,
@@ -296,8 +367,6 @@ class AutomationJobModel {
ownerId: ownerId ?? this.ownerId, ownerId: ownerId ?? this.ownerId,
bootstrapKey: bootstrapKey ?? this.bootstrapKey, bootstrapKey: bootstrapKey ?? this.bootstrapKey,
title: title ?? this.title, title: title ?? this.title,
scheduleType: scheduleType ?? this.scheduleType,
runAt: runAt ?? this.runAt,
timezone: timezone ?? this.timezone, timezone: timezone ?? this.timezone,
status: status ?? this.status, status: status ?? this.status,
isSystem: isSystem ?? this.isSystem, isSystem: isSystem ?? this.isSystem,
@@ -311,9 +380,9 @@ class AutomationJobModel {
bool get isActive => status.toLowerCase() == 'active'; 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 { class AutomationJobListResponse {
@@ -342,16 +411,12 @@ class AutomationJobListResponse {
class AutomationJobCreateRequest { class AutomationJobCreateRequest {
final String title; final String title;
final String scheduleType;
final String runAt;
final String timezone; final String timezone;
final String status; final String status;
final AutomationJobConfigModel config; final AutomationJobConfigModel config;
AutomationJobCreateRequest({ AutomationJobCreateRequest({
required this.title, required this.title,
required this.scheduleType,
required this.runAt,
required this.timezone, required this.timezone,
required this.status, required this.status,
required this.config, required this.config,
@@ -359,8 +424,6 @@ class AutomationJobCreateRequest {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'title': title, 'title': title,
'schedule_type': scheduleType,
'run_at': runAt,
'timezone': timezone, 'timezone': timezone,
'status': status, 'status': status,
'config': config.toJson(), 'config': config.toJson(),
@@ -371,11 +434,13 @@ class AutomationJobConfigPatchModel {
final String? inputTemplate; final String? inputTemplate;
final List<String>? enabledTools; final List<String>? enabledTools;
final MessageContextConfigModel? context; final MessageContextConfigModel? context;
final ScheduleConfigModel? schedule;
AutomationJobConfigPatchModel({ AutomationJobConfigPatchModel({
this.inputTemplate, this.inputTemplate,
this.enabledTools, this.enabledTools,
this.context, this.context,
this.schedule,
}); });
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -383,22 +448,19 @@ class AutomationJobConfigPatchModel {
if (inputTemplate != null) map['input_template'] = inputTemplate; if (inputTemplate != null) map['input_template'] = inputTemplate;
if (enabledTools != null) map['enabled_tools'] = enabledTools; if (enabledTools != null) map['enabled_tools'] = enabledTools;
if (context != null) map['context'] = context!.toJson(); if (context != null) map['context'] = context!.toJson();
if (schedule != null) map['schedule'] = schedule!.toJson();
return map; return map;
} }
} }
class AutomationJobUpdateRequest { class AutomationJobUpdateRequest {
final String? title; final String? title;
final String? scheduleType;
final String? runAt;
final String? timezone; final String? timezone;
final String? status; final String? status;
final AutomationJobConfigPatchModel? config; final AutomationJobConfigPatchModel? config;
AutomationJobUpdateRequest({ AutomationJobUpdateRequest({
this.title, this.title,
this.scheduleType,
this.runAt,
this.timezone, this.timezone,
this.status, this.status,
this.config, this.config,
@@ -407,8 +469,6 @@ class AutomationJobUpdateRequest {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final map = <String, dynamic>{}; final map = <String, dynamic>{};
if (title != null) map['title'] = title; 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 (timezone != null) map['timezone'] = timezone;
if (status != null) map['status'] = status; if (status != null) map['status'] = status;
if (config != null) map['config'] = config!.toJson(); if (config != null) map['config'] = config!.toJson();
@@ -40,6 +40,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
String _scheduleType = 'daily'; String _scheduleType = 'daily';
String _timezone = 'Asia/Shanghai'; String _timezone = 'Asia/Shanghai';
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0); TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
final Set<int> _selectedWeekdays = <int>{1};
String _contextSource = 'latest_chat'; String _contextSource = 'latest_chat';
String _contextWindowMode = 'day'; String _contextWindowMode = 'day';
int _contextWindowCount = 2; int _contextWindowCount = 2;
@@ -134,8 +135,8 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
_buildSectionTitle('计划配置'), _buildSectionTitle('计划配置'),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
_buildInfoCard([ _buildInfoCard([
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)), _buildInfoRow('周期', _scheduleLabel(job.config.schedule.type)),
_buildInfoRow('执行时间', _displayRunAt(job.runAt)), _buildInfoRow('执行时间', _displayRunAt(job.config.schedule)),
_buildInfoRow('时区', job.timezone), _buildInfoRow('时区', job.timezone),
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'), _buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
]), ]),
@@ -239,7 +240,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
children: [ children: [
_buildBadge(job.isSystem ? '系统预置' : '自定义'), _buildBadge(job.isSystem ? '系统预置' : '自定义'),
_buildBadge(job.isActive ? '已启用' : '未启用'), _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), value: _scheduleLabel(_scheduleType),
onTap: _pickScheduleType, onTap: _pickScheduleType,
), ),
if (_scheduleType == 'weekly') ...[
const SizedBox(height: AppSpacing.sm),
_buildWeekdaySelector(),
],
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
_buildPickerTile( _buildPickerTile(
label: '执行时间', 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) { Widget _buildToolWrap(List<String> tools) {
if (tools.isEmpty) { if (tools.isEmpty) {
return _buildTextBlock('未启用工具'); return _buildTextBlock('未启用工具');
@@ -641,6 +725,9 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
if (picked != null) { if (picked != null) {
setState(() { setState(() {
_scheduleType = picked; _scheduleType = picked;
if (_scheduleType == 'weekly' && _selectedWeekdays.isEmpty) {
_selectedWeekdays.add(1);
}
}); });
} }
} }
@@ -708,15 +795,10 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
return '$hour:$minute:00'; return '$hour:$minute:00';
} }
String _displayRunAt(String runAtRaw) { String _displayRunAt(ScheduleConfigModel schedule) {
try { final hour = schedule.runAt.hour.toString().padLeft(2, '0');
final dt = DateTime.parse(runAtRaw).toLocal(); final minute = schedule.runAt.minute.toString().padLeft(2, '0');
final hour = dt.hour.toString().padLeft(2, '0'); return '$hour:$minute';
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} catch (_) {
return runAtRaw;
}
} }
String _scheduleLabel(String scheduleType) { String _scheduleLabel(String scheduleType) {
@@ -757,8 +839,6 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
final request = AutomationJobCreateRequest( final request = AutomationJobCreateRequest(
title: title, title: title,
scheduleType: _scheduleType,
runAt: _formatTime(_runAt),
timezone: _timezone, timezone: _timezone,
status: 'active', status: 'active',
config: AutomationJobConfigModel( config: AutomationJobConfigModel(
@@ -769,6 +849,13 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
windowMode: _contextWindowMode, windowMode: _contextWindowMode,
windowCount: _contextWindowCount, 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); final success = await _cubit.createJob(request);
@@ -273,4 +273,27 @@ void main() {
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>())); 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() { void main() {
group('agent stage mapping', () { 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', () { test('maps protocol step worker to execution stage label', () {
final stage = stageFromStepName('worker'); final stage = stageFromStepName('worker');
@@ -48,6 +48,7 @@ class _FakeAgUiService extends AgUiService {
_FakeAgUiService() : super(apiClient: _NoopApiClient()); _FakeAgUiService() : super(apiClient: _NoopApiClient());
Completer<SendMessageResult>? pendingResult; Completer<SendMessageResult>? pendingResult;
Completer<HistorySnapshot>? pendingHistory;
Object? nextError; Object? nextError;
@override @override
@@ -67,6 +68,21 @@ class _FakeAgUiService extends AgUiService {
return const SendMessageResult(uploadedAttachments: []); 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) { void emitEvent(AgUiEvent event) {
onEvent(event); onEvent(event);
} }
@@ -189,5 +205,132 @@ void main() {
expect(toolItem.errorMessage, '本次运行已失败'); expect(toolItem.errorMessage, '本次运行已失败');
expect(bloc.state.error, 'runtime execution failed'); 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'; import 'package:social_app/features/settings/data/models/automation_job_model.dart';
void main() { 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', () { group('AutomationJobConfigModel', () {
test('fromJson parses all fields correctly', () { test('fromJson parses schedule correctly', () {
final json = { final json = {
'input_template': 'Hello {{name}}', 'input_template': 'Hello {{name}}',
'enabled_tools': ['tool1', 'tool2'], 'enabled_tools': ['tool1', 'tool2'],
'context': { 'context': {
'source': 'messages', 'source': 'latest_chat',
'window_mode': 'week', 'window_mode': 'day',
'window_count': 5, 'window_count': 5,
}, },
'schedule': {
'type': 'weekly',
'run_at': {'hour': 9, 'minute': 30},
'weekdays': [1, 3, 5],
},
}; };
final model = AutomationJobConfigModel.fromJson(json); final model = AutomationJobConfigModel.fromJson(json);
expect(model.inputTemplate, 'Hello {{name}}'); expect(model.schedule.type, 'weekly');
expect(model.enabledTools, ['tool1', 'tool2']); expect(model.schedule.runAt.hour, 9);
expect(model.context.source, 'messages'); expect(model.schedule.runAt.minute, 30);
expect(model.context.windowMode, 'week'); expect(model.schedule.weekdays, [1, 3, 5]);
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);
}); });
}); });
@@ -79,8 +35,6 @@ void main() {
'owner_id': 'user-456', 'owner_id': 'user-456',
'bootstrap_key': 'key-789', 'bootstrap_key': 'key-789',
'title': 'Daily Report', 'title': 'Daily Report',
'schedule_type': 'DAILY',
'run_at': '09:00:00',
'timezone': 'America/New_York', 'timezone': 'America/New_York',
'status': 'ACTIVE', 'status': 'ACTIVE',
'is_system': false, 'is_system': false,
@@ -92,6 +46,10 @@ void main() {
'window_mode': 'day', 'window_mode': 'day',
'window_count': 2, 'window_count': 2,
}, },
'schedule': {
'type': 'daily',
'run_at': {'hour': 9, 'minute': 0},
},
}, },
'next_run_at': '2024-01-15T09:00:00Z', 'next_run_at': '2024-01-15T09:00:00Z',
'last_run_at': '2024-01-14T09:00:00Z', 'last_run_at': '2024-01-14T09:00:00Z',
@@ -103,117 +61,19 @@ void main() {
expect(model.id, 'job-123'); expect(model.id, 'job-123');
expect(model.ownerId, 'user-456'); expect(model.ownerId, 'user-456');
expect(model.bootstrapKey, 'key-789');
expect(model.title, 'Daily Report'); expect(model.title, 'Daily Report');
expect(model.scheduleType, 'DAILY'); expect(model.config.schedule.type, 'daily');
expect(model.runAt, '09:00:00'); expect(model.config.schedule.runAt.hour, 9);
expect(model.timezone, 'America/New_York'); expect(model.timezone, 'America/New_York');
expect(model.status, 'ACTIVE'); expect(model.isDaily, isTrue);
expect(model.isSystem, false); expect(model.isWeekly, isFalse);
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);
}); });
}); });
group('AutomationJobCreateRequest', () { group('AutomationJobCreateRequest', () {
test('toJson serializes correctly', () { test('toJson serializes schedule under config', () {
final request = AutomationJobCreateRequest( final request = AutomationJobCreateRequest(
title: 'New Job', title: 'New Job',
scheduleType: 'DAILY',
runAt: '10:00:00',
timezone: 'UTC', timezone: 'UTC',
status: 'ACTIVE', status: 'ACTIVE',
config: AutomationJobConfigModel( config: AutomationJobConfigModel(
@@ -224,74 +84,47 @@ void main() {
windowMode: 'day', windowMode: 'day',
windowCount: 2, windowCount: 2,
), ),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 10, minute: 0),
),
), ),
); );
final json = request.toJson(); final json = request.toJson();
expect(json['title'], 'New Job'); expect(json['title'], 'New Job');
expect(json['schedule_type'], 'DAILY');
expect(json['run_at'], '10:00:00');
expect(json['timezone'], 'UTC'); expect(json['timezone'], 'UTC');
expect(json['status'], 'ACTIVE'); expect(json['status'], 'ACTIVE');
expect(json['config'], { expect((json['config'] as Map<String, dynamic>)['schedule'], {
'input_template': 'Hello', 'type': 'daily',
'enabled_tools': ['tool1'], 'run_at': {'hour': 10, 'minute': 0},
'context': { });
'source': 'latest_chat', expect(json.containsKey('run_at'), isFalse);
'window_mode': 'day', expect(json.containsKey('schedule_type'), isFalse);
'window_count': 2, });
}, });
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', id: '1',
ownerId: 'owner1', ownerId: 'owner1',
title: 'Test Job', title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC', timezone: 'UTC',
status: 'ACTIVE', status: 'ACTIVE',
isSystem: false, isSystem: false,
@@ -31,6 +29,10 @@ void main() {
windowMode: 'day', windowMode: 'day',
windowCount: 2, windowCount: 2,
), ),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
), ),
nextRunAt: DateTime(2024, 1, 1), nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1),
@@ -21,8 +21,6 @@ void main() {
id: '1', id: '1',
ownerId: 'owner1', ownerId: 'owner1',
title: 'Test Job', title: 'Test Job',
scheduleType: 'DAILY',
runAt: '08:00:00',
timezone: 'UTC', timezone: 'UTC',
status: 'ACTIVE', status: 'ACTIVE',
isSystem: false, isSystem: false,
@@ -34,6 +32,10 @@ void main() {
windowMode: 'day', windowMode: 'day',
windowCount: 2, windowCount: 2,
), ),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
), ),
nextRunAt: DateTime(2024, 1, 1), nextRunAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1),
@@ -173,8 +175,6 @@ void main() {
act: (c) => c.createJob( act: (c) => c.createJob(
AutomationJobCreateRequest( AutomationJobCreateRequest(
title: 'New Job', title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
status: 'active', status: 'active',
config: AutomationJobConfigModel( config: AutomationJobConfigModel(
@@ -185,6 +185,10 @@ void main() {
windowMode: 'day', windowMode: 'day',
windowCount: 2, windowCount: 2,
), ),
schedule: ScheduleConfigModel(
type: 'daily',
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
),
), ),
), ),
), ),
@@ -210,8 +214,6 @@ void main() {
act: (c) => c.createJob( act: (c) => c.createJob(
AutomationJobCreateRequest( AutomationJobCreateRequest(
title: 'New Job', title: 'New Job',
scheduleType: 'daily',
runAt: '08:00:00',
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
status: 'active', status: 'active',
config: AutomationJobConfigModel( config: AutomationJobConfigModel(
@@ -222,6 +224,10 @@ void main() {
windowMode: 'day', windowMode: 'day',
windowCount: 2, 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 content = user_blocks
user_msg = Msg(name="user", role="user", content=content) user_msg = Msg(name="user", role="user", content=content)
return [user_msg, *context_messages] return [*context_messages, user_msg]
async def _run_worker_stage( async def _run_worker_stage(
self, self,
@@ -1,11 +1,23 @@
input_template: | input_template: |
你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具 你正在执行一次“自动化记忆回顾与整理”任务
步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。
步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。 任务目标:
步骤3:对新增或变化的信息,调用 memory_write 执行写入 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化
步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串 2) 对已经失效、被否定或明显过期的信息执行遗忘
步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写 3) 对新增且有证据支持的信息执行写入
输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。 4) 严禁编造;没有证据就不要写入。
5) 只更新最小必要字段,避免过度覆盖。
输出要求:
- 必须使用以下固定格式输出;每一行都要有:
【记忆回顾】<一句人性化总结,说明今天主要发生了什么>
【新增记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【遗忘记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明“可继续观察”>
表达风格:
- 语言自然、温和、可读,像助理在做每日回顾。
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
enabled_tools: enabled_tools:
- memory.write - memory.write
- memory.forget - memory.forget
@@ -18,3 +30,4 @@ schedule:
run_at: run_at:
hour: 8 hour: 8
minute: 0 minute: 0
weekdays: null
-8
View File
@@ -45,14 +45,6 @@ class AutomationJob(TimestampMixin, SoftDeleteMixin, Base):
nullable=False, nullable=False,
default=dict, 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( next_run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=False, nullable=False,
+16 -5
View File
@@ -5,7 +5,7 @@ from enum import Enum
from uuid import UUID from uuid import UUID
from core.agentscope.tools.tool_config import AgentTool 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 AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType from models.automation_jobs import AutomationJobStatus, ScheduleType
@@ -40,6 +40,21 @@ class ScheduleConfig(BaseModel):
type: ScheduleType type: ScheduleType
run_at: ScheduleRunAt 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): class RuntimeConfig(BaseModel):
@@ -66,8 +81,6 @@ class AutomationJob(BaseModel):
bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64) bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64)
title: str = Field(..., min_length=1, max_length=255) title: str = Field(..., min_length=1, max_length=255)
config: AutomationJobConfig config: AutomationJobConfig
schedule_type: ScheduleType
run_at: datetime
next_run_at: datetime next_run_at: datetime
timezone: str = Field(default="UTC", min_length=1, max_length=50) timezone: str = Field(default="UTC", min_length=1, max_length=50)
last_run_at: datetime | None = None last_run_at: datetime | None = None
@@ -84,8 +97,6 @@ class AutomationJob(BaseModel):
bootstrap_key=obj.bootstrap_key, bootstrap_key=obj.bootstrap_key,
title=obj.title, title=obj.title,
config=AutomationJobConfig.model_validate(obj.config or {}), config=AutomationJobConfig.model_validate(obj.config or {}),
schedule_type=obj.schedule_type,
run_at=obj.run_at,
next_run_at=obj.next_run_at, next_run_at=obj.next_run_at,
timezone=obj.timezone, timezone=obj.timezone,
last_run_at=obj.last_run_at, last_run_at=obj.last_run_at,
@@ -7,12 +7,7 @@ from typing import Any
import yaml import yaml
from core.agentscope.tools.tool_config import AgentTool from schemas.automation import AutomationJobConfig
from models.automation_jobs import ScheduleType
from schemas.automation import (
AutomationJobConfig,
MessageContextConfig,
)
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _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 {} loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict): if not isinstance(loaded, dict):
raise ValueError(f"invalid automation config format: {path}") raise ValueError(f"invalid automation config format: {path}")
config = AutomationJobConfig.model_validate(loaded) return 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
+37 -34
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, time, timedelta
from typing import Protocol from typing import Protocol
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from models.memories import MemoryType from models.memories import MemoryType
from models.profile import Profile 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.memories.memory_content import UserMemoryContent, WorkProfileContent
from schemas.user.context import parse_profile_settings from schemas.user.context import parse_profile_settings
from v1.auth.automation_static_config import load_static_automation_job_config from v1.auth.automation_static_config import load_static_automation_job_config
@@ -44,9 +44,7 @@ class RegistrationBootstrapRepository:
title: str, title: str,
config: AutomationJobConfig, config: AutomationJobConfig,
timezone_name: str, timezone_name: str,
run_at: datetime,
next_run_at: datetime, next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool: ) -> bool:
stmt = ( stmt = (
insert(AutomationJob) insert(AutomationJob)
@@ -56,8 +54,6 @@ class RegistrationBootstrapRepository:
bootstrap_key=bootstrap_key, bootstrap_key=bootstrap_key,
title=title, title=title,
config=config.model_dump(mode="json"), config=config.model_dump(mode="json"),
schedule_type=schedule_type,
run_at=run_at,
next_run_at=next_run_at, next_run_at=next_run_at,
timezone=timezone_name, timezone=timezone_name,
status=AutomationJobStatus.ACTIVE, status=AutomationJobStatus.ACTIVE,
@@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
title: str, title: str,
config: AutomationJobConfig, config: AutomationJobConfig,
timezone_name: str, timezone_name: str,
run_at: datetime,
next_run_at: datetime, next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool: ... ) -> bool: ...
async def upsert_initial_memory( async def upsert_initial_memory(
@@ -123,35 +117,48 @@ class SessionLike(Protocol):
async def rollback(self) -> None: ... async def rollback(self) -> None: ...
def compute_next_local_time_utc( def compute_first_run_at_utc(
*, *,
now_utc: datetime, now_utc: datetime,
timezone_name: str, timezone_name: str,
local_hour: int, schedule: ScheduleConfig,
local_minute: int, ) -> datetime:
schedule_type: ScheduleType,
) -> tuple[datetime, datetime]:
try: try:
timezone_obj = ZoneInfo(timezone_name) timezone_obj = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
timezone_obj = ZoneInfo("UTC") timezone_obj = ZoneInfo("UTC")
local_now = now_utc.astimezone(timezone_obj) local_now = now_utc.astimezone(timezone_obj)
today_run_local = local_now.replace( run_clock = time(
hour=local_hour, hour=schedule.run_at.hour,
minute=local_minute, minute=schedule.run_at.minute,
second=0, tzinfo=timezone_obj,
microsecond=0,
) )
run_local = (
today_run_local if schedule.type == ScheduleType.DAILY:
if local_now <= today_run_local candidate_local = datetime.combine(local_now.date(), run_clock)
else today_run_local + timedelta(days=1) if candidate_local <= local_now:
) candidate_local = candidate_local + timedelta(days=1)
if schedule_type == ScheduleType.WEEKLY: return candidate_local.astimezone(UTC)
next_local = run_local + timedelta(weeks=1)
else: weekdays = schedule.weekdays or []
next_local = run_local + timedelta(days=1) if not weekdays:
return run_local.astimezone(UTC), next_local.astimezone(UTC) 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: class RegistrationAutomationBootstrapService:
@@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService:
raise ValueError( raise ValueError(
f"bootstrap job {bootstrap_key} has no schedule configured" 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), now_utc=datetime.now(UTC),
timezone_name=timezone_name, timezone_name=timezone_name,
local_hour=schedule.run_at.hour, schedule=schedule,
local_minute=schedule.run_at.minute,
schedule_type=schedule.type,
) )
inserted = ( inserted = (
await self._repository.insert_bootstrap_automation_job_if_absent( await self._repository.insert_bootstrap_automation_job_if_absent(
@@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService:
title=str(definition["title"]), title=str(definition["title"]),
config=job_config, config=job_config,
timezone_name=timezone_name, timezone_name=timezone_name,
run_at=run_at,
next_run_at=next_run_at, next_run_at=next_run_at,
schedule_type=schedule.type,
) )
) )
inserted_any = inserted_any or inserted 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 core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession, SessionType from models.agent_chat_session import AgentChatSession, SessionType
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from schemas.automation import AutomationJobConfig, ScheduleConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from v1.automation_jobs.schemas import ( from v1.automation_jobs.schemas import (
@@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
return ZoneInfo("UTC") return ZoneInfo("UTC")
def _compute_initial_next_run_at( def _compute_next_run_at(
self, self,
*, *,
run_at: time, schedule: ScheduleConfig,
timezone_str: str, timezone_str: str,
now_utc: datetime, now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime: ) -> datetime:
tz = self._resolve_timezone(timezone_str) tz = self._resolve_timezone(timezone_str)
local_now = now_utc.astimezone(tz) local_now = now_utc.astimezone(tz)
run_at_local = datetime.combine(local_now.date(), run_at, tz) run_clock = time(
if run_at_local.tzinfo is None: hour=schedule.run_at.hour,
run_at_local = run_at_local.replace(tzinfo=tz) minute=schedule.run_at.minute,
next_run_at = run_at_local tzinfo=tz,
if next_run_at <= local_now: )
if schedule_type == ScheduleType.DAILY:
next_run_at = next_run_at + timedelta(days=1) if schedule.type == ScheduleType.DAILY:
else: candidate_local = datetime.combine(local_now.date(), run_clock)
next_run_at = next_run_at + timedelta(weeks=1) if candidate_local <= local_now:
return next_run_at.astimezone(timezone.utc) 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( async def create(
self, self,
@@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobCreateRequest, data: AutomationJobCreateRequest,
) -> AutomationJob: ) -> AutomationJob:
now_utc = datetime.now(tz=timezone.utc) now_utc = datetime.now(tz=timezone.utc)
timezone_obj = self._resolve_timezone(data.timezone) schedule = data.config.schedule
local_now = now_utc.astimezone(timezone_obj) if schedule is None:
date_ref = local_now.date() raise ValueError("config.schedule is required")
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
run_at_datetime = local_dt.astimezone(timezone.utc) next_run_at = self._compute_next_run_at(
next_run_at = self._compute_initial_next_run_at( schedule=schedule,
run_at=data.run_at,
timezone_str=data.timezone, timezone_str=data.timezone,
now_utc=now_utc, now_utc=now_utc,
schedule_type=data.schedule_type,
) )
new_job = AutomationJob( new_job = AutomationJob(
@@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
created_by=owner_id, created_by=owner_id,
bootstrap_key=None, bootstrap_key=None,
title=data.title, title=data.title,
schedule_type=data.schedule_type,
run_at=run_at_datetime,
timezone=data.timezone, timezone=data.timezone,
status=data.status, status=data.status,
config=data.config.model_dump(mode="json"), config=data.config.model_dump(mode="json"),
@@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobUpdateRequest, data: AutomationJobUpdateRequest,
) -> AutomationJob | None: ) -> AutomationJob | None:
update_values: dict[str, object] = {} 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: if data.title is not None:
update_values["title"] = data.title 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: if data.timezone is not None:
update_values["timezone"] = data.timezone update_values["timezone"] = data.timezone
if data.status is not None: if data.status is not None:
update_values["status"] = data.status update_values["status"] = data.status
merged_config_raw: dict[str, object] = dict(existing_job.config or {})
if data.config is not None: if data.config is not None:
if existing_job is None: merged_config_raw = {
existing_job = await self.get_by_id(job_id) **merged_config_raw,
if existing_job is None:
return None
merged_config = {
**existing_job.config,
**data.config.model_dump(mode="json", exclude_unset=True), **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: 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) return await self.update_by_id(job_id, update_values)
+9 -11
View File
@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time from datetime import datetime
from typing import Self from typing import Self
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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 AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType from models.automation_jobs import AutomationJobStatus
from schemas.automation import AutomationJobConfig from schemas.automation import AutomationJobConfig
@@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel):
owner_id: UUID owner_id: UUID
bootstrap_key: str | None = None bootstrap_key: str | None = None
title: str title: str
schedule_type: ScheduleType
run_at: time
timezone: str timezone: str
status: AutomationJobStatus status: AutomationJobStatus
is_system: bool is_system: bool
@@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel):
owner_id=obj.owner_id, owner_id=obj.owner_id,
bootstrap_key=obj.bootstrap_key, bootstrap_key=obj.bootstrap_key,
title=obj.title, title=obj.title,
schedule_type=obj.schedule_type,
run_at=obj.run_at.time(),
timezone=obj.timezone, timezone=obj.timezone,
status=obj.status, status=obj.status,
is_system=obj.bootstrap_key is not None, is_system=obj.bootstrap_key is not None,
@@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255) 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) timezone: str = Field(..., min_length=1, max_length=50)
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE) status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
config: AutomationJobConfig 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") @field_validator("timezone")
@classmethod @classmethod
def validate_timezone(cls, value: str) -> str: def validate_timezone(cls, value: str) -> str:
@@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255) 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) timezone: str | None = Field(None, min_length=1, max_length=50)
status: AutomationJobStatus | None = None status: AutomationJobStatus | None = None
config: AutomationJobConfig | None = None config: AutomationJobConfig | None = None
+50 -10
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, time, timedelta, timezone
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException, status from fastapi import HTTPException, status
from models.automation_jobs import ScheduleType from models.automation_jobs import ScheduleType
@@ -11,6 +12,7 @@ from schemas.automation import (
AutomationJob as AutomationJobSchema, AutomationJob as AutomationJobSchema,
MessageContextConfig, MessageContextConfig,
RuntimeConfig, RuntimeConfig,
ScheduleConfig,
) )
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@@ -68,15 +70,53 @@ class DispatchFn(Protocol):
def _compute_next_run_at( def _compute_next_run_at(
*, *,
current_next_run_at: datetime, schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime, now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime: ) -> datetime:
delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7) try:
next_run_at = current_next_run_at tz = ZoneInfo(timezone_str)
while next_run_at <= now_utc: except ZoneInfoNotFoundError:
next_run_at = next_run_at + delta tz = ZoneInfo("UTC")
return next_run_at
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) @dataclass(slots=True)
@@ -129,9 +169,9 @@ class AutomationJobsService:
await self._repository.update_job_schedule( await self._repository.update_job_schedule(
job_id=job.id, job_id=job.id,
next_run_at=_compute_next_run_at( 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, now_utc=now_utc,
schedule_type=job.schedule_type,
), ),
last_run_at=now_utc, last_run_at=now_utc,
) )
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time, timezone from datetime import datetime, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -29,13 +29,24 @@ def _make_job_response(
id=job_id or uuid4(), id=job_id or uuid4(),
owner_id=owner_id or uuid4(), owner_id=owner_id or uuid4(),
title=overrides.get("title", "Test Job"), 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"), timezone=overrides.get("timezone", "Asia/Shanghai"),
status=overrides.get("status", "active"), status=overrides.get("status", "active"),
is_system=overrides.get("is_system", False), is_system=overrides.get("is_system", False),
config=overrides.get( 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), next_run_at=overrides.get("next_run_at", now),
created_at=overrides.get("created_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", "/api/v1/automation-jobs",
json={ json={
"title": "New Job", "title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"config": { "config": {
"input_template": "Hello", "input_template": "Hello",
"enabled_tools": [], "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", "/api/v1/automation-jobs",
json={ json={
"title": "New Job", "title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"status": "active", "status": "active",
"config": { "config": {
"input_template": "Hello", "input_template": "Hello",
"enabled_tools": [], "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", "/api/v1/automation-jobs",
json={ json={
"title": "New Job", "title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"status": "active", "status": "active",
"config": { "config": {
"input_template": "Hello", "input_template": "Hello",
"enabled_tools": [], "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: def test_get_automation_job_returns_job() -> None:
user_id = uuid4() user_id = uuid4()
job_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_job_id = job_id
captured_owner_id = user_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: def test_update_automation_job_succeeds() -> None:
user_id = uuid4() user_id = uuid4()
job_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: class FakeService:
async def update( 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 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( def test_build_model_omits_none_generate_kwargs(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -8,6 +8,8 @@ import pytest
from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType
from schemas.automation import ( from schemas.automation import (
RuntimeConfig, RuntimeConfig,
ScheduleConfig,
ScheduleRunAt,
) )
from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at
@@ -50,7 +52,6 @@ def _make_orm_job(
*, *,
job_id: UUID | None = None, job_id: UUID | None = None,
owner_id: UUID | None = None, owner_id: UUID | None = None,
schedule_type: ScheduleType = ScheduleType.DAILY,
next_run_at: datetime | None = None, next_run_at: datetime | None = None,
) -> OrmAutomationJob: ) -> OrmAutomationJob:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
@@ -66,9 +67,14 @@ def _make_orm_job(
"window_count": 2, "window_count": 2,
}, },
"input_template": "auto input: {date}", "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), next_run_at=next_run_at or now - timedelta(minutes=1),
timezone="UTC", timezone="UTC",
last_run_at=None, 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: def test_compute_next_run_at_daily() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) 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( 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, now_utc=now,
schedule_type=ScheduleType.DAILY,
) )
assert computed == datetime(2026, 3, 20, 11, 0, tzinfo=timezone.utc) 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: def test_compute_next_run_at_weekly() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) 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( 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, now_utc=now,
schedule_type=ScheduleType.WEEKLY,
) )
assert computed == datetime(2026, 3, 24, 11, 0, tzinfo=timezone.utc) 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", "memory.forget",
] ]
assert config.input_template is not None assert config.input_template is not None
assert "提取" in config.input_template assert "回顾" in config.input_template
assert "遗忘" in config.input_template assert "遗忘" in config.input_template
assert config.schedule is not None assert config.schedule is not None
assert config.schedule.type.value == "daily" assert config.schedule.type.value == "daily"
@@ -8,38 +8,39 @@ import pytest
from models.automation_jobs import ScheduleType from models.automation_jobs import ScheduleType
from v1.auth.registration_bootstrap import ( 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) 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, now_utc=now_utc,
timezone_name="Asia/Shanghai", timezone_name="Asia/Shanghai",
local_hour=8, schedule=ScheduleConfig(
local_minute=0, type=ScheduleType.DAILY,
schedule_type=ScheduleType.DAILY, run_at=ScheduleRunAt(hour=8, minute=0),
),
) )
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 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) 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, now_utc=now_utc,
timezone_name="Asia/Shanghai", timezone_name="Asia/Shanghai",
local_hour=8, schedule=ScheduleConfig(
local_minute=0, type=ScheduleType.DAILY,
schedule_type=ScheduleType.DAILY, run_at=ScheduleRunAt(hour=8, minute=0),
),
) )
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1,21 +1,22 @@
from datetime import datetime, time, timezone
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from models.automation_jobs import AutomationJobStatus, ScheduleType 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 ( from schemas.automation import (
AgentTool, AgentTool,
AutomationJobConfig, AutomationJobConfig,
ContextSource, ContextSource,
ContextWindowMode, ContextWindowMode,
MessageContextConfig, 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_mode=ContextWindowMode.DAY,
window_count=2, window_count=2,
), ),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
) )
def _make_create_request() -> AutomationJobCreateRequest: def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest( return AutomationJobCreateRequest(
title="Test Job", title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai", timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE, status=AutomationJobStatus.ACTIVE,
config=_make_config(), config=_make_config(),
@@ -57,9 +60,6 @@ async def test_list_by_owner_returns_jobs() -> None:
assert result == [job_one, job_two] assert result == [job_one, job_two]
session.execute.assert_awaited_once() 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 @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) result = await repository.count_user_jobs(owner_id)
assert result == 3 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 @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 = AsyncMock()
session.add = MagicMock() session.add = MagicMock()
repository = AutomationJobsRepository(session) repository = AutomationJobsRepository(session)
@@ -93,67 +87,13 @@ async def test_create_sets_bootstrap_key_to_none() -> None:
await repository.create(owner_id, data) await repository.create(owner_id, data)
session.add.assert_called_once() 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] call_args = session.add.call_args[0][0]
assert call_args.owner_id == owner_id assert call_args.owner_id == owner_id
assert call_args.bootstrap_key is None
assert call_args.title == data.title assert call_args.title == data.title
assert call_args.schedule_type == data.schedule_type
assert call_args.timezone == data.timezone assert call_args.timezone == data.timezone
assert call_args.status == data.status assert call_args.status == data.status
assert call_args.next_run_at is not None
@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()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -161,9 +101,8 @@ async def test_update_returns_none_when_job_not_found() -> None:
session = AsyncMock() session = AsyncMock()
repository = AutomationJobsRepository(session) repository = AutomationJobsRepository(session)
job_id = uuid4() job_id = uuid4()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = None repository.get_by_id = AsyncMock(return_value=None)
session.execute.return_value = execute_result
data = AutomationJobUpdateRequest(title="Updated Title") data = AutomationJobUpdateRequest(title="Updated Title")
result = await repository.update(job_id, data) 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 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 @pytest.mark.asyncio
async def test_soft_delete_calls_soft_delete_by_id() -> None: async def test_soft_delete_calls_soft_delete_by_id() -> None:
session = AsyncMock() 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) await repository.list_due_jobs(now_utc=MagicMock(), limit=10)
session.execute.assert_awaited_once() 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 datetime import datetime
from unittest.mock import MagicMock from unittest.mock import MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest
from pydantic import ValidationError from pydantic import ValidationError
from schemas.automation import AgentTool, AutomationJobConfig
from v1.automation_jobs.schemas import ( from v1.automation_jobs.schemas import (
AutomationJobCreateRequest, AutomationJobCreateRequest,
AutomationJobUpdateRequest,
AutomationJobResponse, AutomationJobResponse,
AutomationJobUpdateRequest,
) )
from schemas.automation import AgentTool, AutomationJobConfig
class TestIsSystemProperty: def _mock_orm_job() -> MagicMock:
def test_is_system_true_when_bootstrap_key_present(self): mock_orm_job = MagicMock()
mock_orm_job = MagicMock() mock_orm_job.id = uuid4()
mock_orm_job.id = uuid4() mock_orm_job.owner_id = uuid4()
mock_orm_job.owner_id = uuid4() mock_orm_job.bootstrap_key = "memory_extraction"
mock_orm_job.bootstrap_key = "memory_extraction" mock_orm_job.title = "Test Job"
mock_orm_job.title = "Test Job" mock_orm_job.config = {
mock_orm_job.schedule_type = "daily" "input_template": "Hello",
mock_orm_job.run_at = datetime.now() "enabled_tools": ["memory.write", "memory.forget"],
mock_orm_job.config = { "context": {
"input_template": "Hello", "source": "latest_chat",
"enabled_tools": [], "window_mode": "day",
"context": {}, "window_count": 2,
} },
mock_orm_job.schedule_type = "daily" "schedule": {
mock_orm_job.status = "active" "type": "daily",
mock_orm_job.timezone = "Asia/Shanghai" "run_at": {"hour": 8, "minute": 0},
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.status = "active"
mock_orm_job.updated_at = datetime.now() mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.deleted_at = None mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job) mock_orm_job.created_at = datetime.now()
assert resp.is_system is True mock_orm_job.updated_at = datetime.now()
return mock_orm_job
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
class TestFromOrm: def test_response_is_system_true_when_bootstrap_key_present() -> None:
def test_run_at_converted_from_datetime_to_time(self): resp = AutomationJobResponse.from_orm(_mock_orm_job())
run_at_datetime = datetime(2024, 6, 15, 14, 30, 0) assert resp.is_system is True
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"
class TestTimezoneValidation: def test_response_parses_schedule_from_config() -> None:
def test_valid_timezone(self): resp = AutomationJobResponse.from_orm(_mock_orm_job())
request = AutomationJobCreateRequest.model_validate( 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", "title": "Test Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"config": { "config": {
"input_template": "Hello", "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_create_request_valid_timezone() -> None:
def test_all_fields_optional(self): request = AutomationJobCreateRequest.model_validate(
patch = AutomationJobConfig.model_validate({}) {
assert patch.input_template is None "title": "Test Job",
assert patch.enabled_tools is None "timezone": "Asia/Shanghai",
assert patch.context is None "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): def test_update_timezone_validation() -> None:
with pytest.raises(ValidationError): request = AutomationJobUpdateRequest.model_validate(
AutomationJobConfig.model_validate( {"timezone": "America/New_York"}
{ )
"input_template": "Test", assert request.timezone == "America/New_York"
"unknown_field": "value",
} 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 unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@@ -23,6 +23,8 @@ from schemas.automation import (
ContextSource, ContextSource,
ContextWindowMode, ContextWindowMode,
MessageContextConfig, MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
) )
@@ -35,14 +37,16 @@ def _make_config() -> AutomationJobConfig:
window_mode=ContextWindowMode.DAY, window_mode=ContextWindowMode.DAY,
window_count=2, window_count=2,
), ),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
) )
def _make_create_request() -> AutomationJobCreateRequest: def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest( return AutomationJobCreateRequest(
title="Test Job", title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai", timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE, status=AutomationJobStatus.ACTIVE,
config=_make_config(), config=_make_config(),
@@ -50,18 +54,28 @@ def _make_create_request() -> AutomationJobCreateRequest:
def _make_job( def _make_job(
owner_id: MagicMock | None = None, bootstrap_key: str | None = None owner_id: UUID | None = None, bootstrap_key: str | None = None
) -> MagicMock: ) -> MagicMock:
job = MagicMock() job = MagicMock()
job.id = uuid4() job.id = uuid4()
job.owner_id = owner_id or uuid4() job.owner_id = owner_id or uuid4()
job.bootstrap_key = bootstrap_key job.bootstrap_key = bootstrap_key
job.title = "Test Job" 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.timezone = "Asia/Shanghai"
job.status = AutomationJobStatus.ACTIVE 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.next_run_at = datetime(2024, 1, 2, 9, 0, 0, tzinfo=timezone.utc)
job.last_run_at = None job.last_run_at = None
job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc) job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
@@ -210,7 +224,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound): with pytest.raises(AutomationJobNotFound):
await service.update( await service.update(
job_id, owner_id, AutomationJobUpdateRequest(title="New") job_id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -225,7 +241,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound): with pytest.raises(AutomationJobNotFound):
await service.update( await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New") job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -239,7 +257,9 @@ class TestUpdate:
with pytest.raises(SystemJobModificationForbidden): with pytest.raises(SystemJobModificationForbidden):
await service.update( await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New") job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
) )
repository.update.assert_not_called() repository.update.assert_not_called()
@@ -257,12 +277,15 @@ class TestUpdate:
repository.update.return_value = updated_job repository.update.return_value = updated_job
result = await service.update( 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" assert result.title == "Updated Title"
repository.update.assert_awaited_once_with( 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() session.commit.assert_awaited_once()
@@ -278,7 +301,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound): with pytest.raises(AutomationJobNotFound):
await service.update( await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New") job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -293,7 +318,9 @@ class TestUpdate:
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
await service.update( 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 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.