From 389f5248fc24788b3793f87cdfca1699f7456bff Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 24 Mar 2026 18:19:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=E4=BB=BB=E5=8A=A1=E8=B0=83=E5=BA=A6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E8=81=8A=E5=A4=A9=E6=B5=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/data/services/ag_ui_service.dart | 12 + .../chat/presentation/bloc/agent_stage.dart | 5 +- .../chat/presentation/bloc/chat_bloc.dart | 52 ++- .../data/models/automation_job_model.dart | 128 ++++++-- .../ui/screens/job_detail_screen.dart | 115 ++++++- .../data/services/ag_ui_service_test.dart | 23 ++ .../agent_stage_mapping_test.dart | 7 + .../chat_bloc_attachment_sync_test.dart | 143 +++++++++ .../models/automation_job_model_test.dart | 273 ++++------------ .../cubits/automation_jobs_cubit_test.dart | 6 +- .../cubits/job_detail_cubit_test.dart | 18 +- ...0001_automation_jobs_schedule_in_config.py | 111 +++++++ backend/src/core/agentscope/runtime/runner.py | 2 +- .../static/automation/memory_extraction.yaml | 27 +- backend/src/models/automation_jobs.py | 8 - backend/src/schemas/automation/__init__.py | 21 +- .../src/v1/auth/automation_static_config.py | 23 +- backend/src/v1/auth/registration_bootstrap.py | 71 +++-- backend/src/v1/automation_jobs/repository.py | 137 ++++---- backend/src/v1/automation_jobs/schemas.py | 20 +- backend/src/v1/automation_jobs/service.py | 60 +++- .../v1/automation_jobs/test_router.py | 63 +++- .../core/agentscope/runtime/test_runner.py | 22 ++ .../unit/core/automation/test_scheduler.py | 29 +- .../test_memory_automation_static_config.py | 2 +- .../test_registration_bootstrap_service.py | 31 +- .../v1/automation_jobs/test_repository.py | 225 ++++--------- .../unit/v1/automation_jobs/test_schemas.py | 297 +++++------------- .../unit/v1/automation_jobs/test_service.py | 57 +++- docs/protocols/models/automation-jobs.md | 44 +++ 30 files changed, 1144 insertions(+), 888 deletions(-) create mode 100644 backend/alembic/versions/20260324_0001_automation_jobs_schedule_in_config.py create mode 100644 docs/protocols/models/automation-jobs.md diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index aaa230d..7edf4c2 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -187,6 +187,7 @@ class AgUiService { String? eventType; String? eventId; var hasBoundExpectedRun = false; + var hasSeenTerminalForRun = false; final dataBuffer = StringBuffer(); final done = Completer(); late final StreamSubscription subscription; @@ -257,6 +258,7 @@ class AgUiService { eventThreadId == null || eventThreadId == threadId; if (isTerminalEvent && (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) { + hasSeenTerminalForRun = true; stopStream(); return; } @@ -291,6 +293,16 @@ class AgUiService { stopStream(error: error, stackTrace: stackTrace); }, onDone: () { + if (streamToken != _activeStreamToken) { + stopStream(); + return; + } + if (!hasSeenTerminalForRun) { + stopStream( + error: StateError('SSE closed before terminal event for run'), + ); + return; + } stopStream(); }, cancelOnError: false, diff --git a/apps/lib/features/chat/presentation/bloc/agent_stage.dart b/apps/lib/features/chat/presentation/bloc/agent_stage.dart index 821aabf..b4073bb 100644 --- a/apps/lib/features/chat/presentation/bloc/agent_stage.dart +++ b/apps/lib/features/chat/presentation/bloc/agent_stage.dart @@ -1,7 +1,9 @@ -enum AgentStage { execution, memory } +enum AgentStage { routing, execution, memory } AgentStage? stageFromStepName(String value) { switch (value) { + case 'router': + return AgentStage.routing; case 'worker': return AgentStage.execution; case 'memory': @@ -13,6 +15,7 @@ AgentStage? stageFromStepName(String value) { String stageLabel(AgentStage? stage) { return switch (stage) { + AgentStage.routing => '意图识别中', AgentStage.execution => '任务执行中', AgentStage.memory => '记忆提取中', null => '任务处理中', diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index ef8e723..ada3df1 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -408,6 +408,11 @@ class ChatBloc extends Cubit { uploadedAttachments: sendResult.uploadedAttachments, ); } catch (error) { + final sseClosedBeforeTerminal = _isSseClosedBeforeTerminalError(error); + var recoveredFromHistory = false; + if (sseClosedBeforeTerminal) { + recoveredFromHistory = await _recoverFromAbnormalSseClose(); + } _markAttachmentUploadDone(messageId); emit( state.copyWith( @@ -415,12 +420,45 @@ class ChatBloc extends Cubit { isWaitingFirstToken: false, isStreaming: false, isCancelling: false, - error: error.toString(), + currentStage: null, + error: sseClosedBeforeTerminal + ? (recoveredFromHistory ? null : '连接中断,请重试') + : error.toString(), ), ); } } + bool _isSseClosedBeforeTerminalError(Object error) { + final text = error.toString().toLowerCase(); + return text.contains('sse closed before terminal event'); + } + + Future _recoverFromAbnormalSseClose() async { + try { + final snapshot = await _service.loadHistory(); + final historyItems = _convertHistoryMessages(snapshot.messages); + final mergedById = { + for (final item in historyItems) item.id: item, + }; + for (final item in state.items) { + mergedById[item.id] = item; + } + final merged = mergedById.values.toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + emit( + state.copyWith( + items: merged, + oldestLoadedDate: _extractDateFromItems(merged), + hasEarlierHistory: snapshot.hasMore, + ), + ); + return true; + } catch (_) { + return false; + } + } + void _syncUploadedAttachments({ required String messageId, required List uploadedAttachments, @@ -484,10 +522,18 @@ class ChatBloc extends Cubit { try { final snapshot = await _service.loadHistory(); final newItems = _convertHistoryMessages(snapshot.messages); - final oldestDate = _extractDateFromItems(newItems); + final mergedById = { + for (final item in newItems) item.id: item, + }; + for (final item in state.items) { + mergedById[item.id] = item; + } + final merged = mergedById.values.toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final oldestDate = _extractDateFromItems(merged); emit( state.copyWith( - items: newItems, + items: merged, oldestLoadedDate: oldestDate, hasEarlierHistory: snapshot.hasMore, ), diff --git a/apps/lib/features/settings/data/models/automation_job_model.dart b/apps/lib/features/settings/data/models/automation_job_model.dart index 6345614..98f8db6 100644 --- a/apps/lib/features/settings/data/models/automation_job_model.dart +++ b/apps/lib/features/settings/data/models/automation_job_model.dart @@ -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? 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 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? weekdays; + + ScheduleConfigModel({required this.type, required this.runAt, this.weekdays}); + + factory ScheduleConfigModel.fromJson(Map? 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? 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?, + ), + weekdays: type.toLowerCase() == 'weekly' ? weekdays ?? [1] : null, + ); + } + + Map toJson() { + final map = {'type': type, 'run_at': runAt.toJson()}; + if (weekdays != null) { + map['weekdays'] = weekdays; + } + return map; + } + + ScheduleConfigModel copyWith({ + String? type, + ScheduleRunAtModel? runAt, + List? weekdays, + }) { + return ScheduleConfigModel( + type: type ?? this.type, + runAt: runAt ?? this.runAt, + weekdays: weekdays ?? this.weekdays, + ); + } +} + class AutomationJobConfigModel { final String inputTemplate; final List enabledTools; final MessageContextConfigModel context; + final ScheduleConfigModel schedule; AutomationJobConfigModel({ required this.inputTemplate, required this.enabledTools, required this.context, + required this.schedule, }); factory AutomationJobConfigModel.fromJson(Map? json) { @@ -109,6 +189,7 @@ class AutomationJobConfigModel { inputTemplate: '', enabledTools: const [], context: MessageContextConfigModel.fromJson(null), + schedule: ScheduleConfigModel.fromJson(null), ); } return AutomationJobConfigModel( @@ -126,6 +207,11 @@ class AutomationJobConfigModel { json['context'] as Map?, ) : MessageContextConfigModel.fromJson(null), + schedule: json['schedule'] != null + ? ScheduleConfigModel.fromJson( + json['schedule'] as Map?, + ) + : ScheduleConfigModel.fromJson(null), ); } @@ -133,17 +219,20 @@ class AutomationJobConfigModel { 'input_template': inputTemplate, 'enabled_tools': enabledTools, 'context': context.toJson(), + 'schedule': schedule.toJson(), }; AutomationJobConfigModel copyWith({ String? inputTemplate, List? enabledTools, MessageContextConfigModel? context, + ScheduleConfigModel? schedule, }) { return AutomationJobConfigModel( inputTemplate: inputTemplate ?? this.inputTemplate, enabledTools: enabledTools ?? this.enabledTools, context: context ?? this.context, + schedule: schedule ?? this.schedule, ); } } @@ -153,8 +242,6 @@ class AutomationJobModel { final String ownerId; final String? bootstrapKey; final String title; - final String scheduleType; - final String runAt; final String timezone; final String status; final bool isSystem; @@ -169,8 +256,6 @@ class AutomationJobModel { required this.ownerId, this.bootstrapKey, required this.title, - required this.scheduleType, - required this.runAt, required this.timezone, required this.status, required this.isSystem, @@ -193,16 +278,6 @@ class AutomationJobModel { fallback: '', ), title: _parseString(json['title'], field: 'title', fallback: ''), - scheduleType: _parseString( - json['schedule_type'], - field: 'schedule_type', - fallback: 'daily', - ), - runAt: _parseString( - json['run_at'], - field: 'run_at', - fallback: '08:00:00', - ), timezone: _parseString( json['timezone'], field: 'timezone', @@ -263,8 +338,6 @@ class AutomationJobModel { 'owner_id': ownerId, 'bootstrap_key': bootstrapKey, 'title': title, - 'schedule_type': scheduleType, - 'run_at': runAt, 'timezone': timezone, 'status': status, 'is_system': isSystem, @@ -280,8 +353,6 @@ class AutomationJobModel { String? ownerId, String? bootstrapKey, String? title, - String? scheduleType, - String? runAt, String? timezone, String? status, bool? isSystem, @@ -296,8 +367,6 @@ class AutomationJobModel { ownerId: ownerId ?? this.ownerId, bootstrapKey: bootstrapKey ?? this.bootstrapKey, title: title ?? this.title, - scheduleType: scheduleType ?? this.scheduleType, - runAt: runAt ?? this.runAt, timezone: timezone ?? this.timezone, status: status ?? this.status, isSystem: isSystem ?? this.isSystem, @@ -311,9 +380,9 @@ class AutomationJobModel { bool get isActive => status.toLowerCase() == 'active'; - bool get isDaily => scheduleType.toLowerCase() == 'daily'; + bool get isDaily => config.schedule.type.toLowerCase() == 'daily'; - bool get isWeekly => scheduleType.toLowerCase() == 'weekly'; + bool get isWeekly => config.schedule.type.toLowerCase() == 'weekly'; } class AutomationJobListResponse { @@ -342,16 +411,12 @@ class AutomationJobListResponse { class AutomationJobCreateRequest { final String title; - final String scheduleType; - final String runAt; final String timezone; final String status; final AutomationJobConfigModel config; AutomationJobCreateRequest({ required this.title, - required this.scheduleType, - required this.runAt, required this.timezone, required this.status, required this.config, @@ -359,8 +424,6 @@ class AutomationJobCreateRequest { Map toJson() => { 'title': title, - 'schedule_type': scheduleType, - 'run_at': runAt, 'timezone': timezone, 'status': status, 'config': config.toJson(), @@ -371,11 +434,13 @@ class AutomationJobConfigPatchModel { final String? inputTemplate; final List? enabledTools; final MessageContextConfigModel? context; + final ScheduleConfigModel? schedule; AutomationJobConfigPatchModel({ this.inputTemplate, this.enabledTools, this.context, + this.schedule, }); Map toJson() { @@ -383,22 +448,19 @@ class AutomationJobConfigPatchModel { if (inputTemplate != null) map['input_template'] = inputTemplate; if (enabledTools != null) map['enabled_tools'] = enabledTools; if (context != null) map['context'] = context!.toJson(); + if (schedule != null) map['schedule'] = schedule!.toJson(); return map; } } class AutomationJobUpdateRequest { final String? title; - final String? scheduleType; - final String? runAt; final String? timezone; final String? status; final AutomationJobConfigPatchModel? config; AutomationJobUpdateRequest({ this.title, - this.scheduleType, - this.runAt, this.timezone, this.status, this.config, @@ -407,8 +469,6 @@ class AutomationJobUpdateRequest { Map toJson() { final map = {}; if (title != null) map['title'] = title; - if (scheduleType != null) map['schedule_type'] = scheduleType; - if (runAt != null) map['run_at'] = runAt; if (timezone != null) map['timezone'] = timezone; if (status != null) map['status'] = status; if (config != null) map['config'] = config!.toJson(); diff --git a/apps/lib/features/settings/ui/screens/job_detail_screen.dart b/apps/lib/features/settings/ui/screens/job_detail_screen.dart index b6ed28f..66e5729 100644 --- a/apps/lib/features/settings/ui/screens/job_detail_screen.dart +++ b/apps/lib/features/settings/ui/screens/job_detail_screen.dart @@ -40,6 +40,7 @@ class _JobDetailScreenState extends State { String _scheduleType = 'daily'; String _timezone = 'Asia/Shanghai'; TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0); + final Set _selectedWeekdays = {1}; String _contextSource = 'latest_chat'; String _contextWindowMode = 'day'; int _contextWindowCount = 2; @@ -134,8 +135,8 @@ class _JobDetailScreenState extends State { _buildSectionTitle('计划配置'), const SizedBox(height: AppSpacing.sm), _buildInfoCard([ - _buildInfoRow('周期', _scheduleLabel(job.scheduleType)), - _buildInfoRow('执行时间', _displayRunAt(job.runAt)), + _buildInfoRow('周期', _scheduleLabel(job.config.schedule.type)), + _buildInfoRow('执行时间', _displayRunAt(job.config.schedule)), _buildInfoRow('时区', job.timezone), _buildInfoRow('状态', job.isActive ? '已启用' : '未启用'), ]), @@ -239,7 +240,7 @@ class _JobDetailScreenState extends State { children: [ _buildBadge(job.isSystem ? '系统预置' : '自定义'), _buildBadge(job.isActive ? '已启用' : '未启用'), - _buildBadge(_scheduleLabel(job.scheduleType)), + _buildBadge(_scheduleLabel(job.config.schedule.type)), ], ), ], @@ -319,6 +320,10 @@ class _JobDetailScreenState extends State { value: _scheduleLabel(_scheduleType), onTap: _pickScheduleType, ), + if (_scheduleType == 'weekly') ...[ + const SizedBox(height: AppSpacing.sm), + _buildWeekdaySelector(), + ], const SizedBox(height: AppSpacing.sm), _buildPickerTile( label: '执行时间', @@ -536,6 +541,85 @@ class _JobDetailScreenState extends State { ); } + Widget _buildWeekdaySelector() { + const weekdayLabels = { + 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 tools) { if (tools.isEmpty) { return _buildTextBlock('未启用工具'); @@ -641,6 +725,9 @@ class _JobDetailScreenState extends State { if (picked != null) { setState(() { _scheduleType = picked; + if (_scheduleType == 'weekly' && _selectedWeekdays.isEmpty) { + _selectedWeekdays.add(1); + } }); } } @@ -708,15 +795,10 @@ class _JobDetailScreenState extends State { return '$hour:$minute:00'; } - String _displayRunAt(String runAtRaw) { - try { - final dt = DateTime.parse(runAtRaw).toLocal(); - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } catch (_) { - return runAtRaw; - } + String _displayRunAt(ScheduleConfigModel schedule) { + final hour = schedule.runAt.hour.toString().padLeft(2, '0'); + final minute = schedule.runAt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; } String _scheduleLabel(String scheduleType) { @@ -757,8 +839,6 @@ class _JobDetailScreenState extends State { final request = AutomationJobCreateRequest( title: title, - scheduleType: _scheduleType, - runAt: _formatTime(_runAt), timezone: _timezone, status: 'active', config: AutomationJobConfigModel( @@ -769,6 +849,13 @@ class _JobDetailScreenState extends State { windowMode: _contextWindowMode, windowCount: _contextWindowCount, ), + schedule: ScheduleConfigModel( + type: _scheduleType, + runAt: ScheduleRunAtModel(hour: _runAt.hour, minute: _runAt.minute), + weekdays: _scheduleType == 'weekly' + ? (_selectedWeekdays.toList()..sort()) + : null, + ), ), ); final success = await _cubit.createJob(request); diff --git a/apps/test/features/chat/data/services/ag_ui_service_test.dart b/apps/test/features/chat/data/services/ag_ui_service_test.dart index 1f36593..0145d49 100644 --- a/apps/test/features/chat/data/services/ag_ui_service_test.dart +++ b/apps/test/features/chat/data/services/ag_ui_service_test.dart @@ -273,4 +273,27 @@ void main() { await expectLater(service.sendMessage('hello'), throwsA(isA())); }); + + 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: [...startedLines]), + ); + + await expectLater( + service.sendMessage('hello'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('SSE closed before terminal event'), + ), + ), + ); + }); } diff --git a/apps/test/features/chat/presentation/agent_stage_mapping_test.dart b/apps/test/features/chat/presentation/agent_stage_mapping_test.dart index 6b1d5cb..66742bf 100644 --- a/apps/test/features/chat/presentation/agent_stage_mapping_test.dart +++ b/apps/test/features/chat/presentation/agent_stage_mapping_test.dart @@ -3,6 +3,13 @@ import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart'; void main() { group('agent stage mapping', () { + test('maps protocol step router to routing stage label', () { + final stage = stageFromStepName('router'); + + expect(stage, AgentStage.routing); + expect(stageLabel(stage), '意图识别中'); + }); + test('maps protocol step worker to execution stage label', () { final stage = stageFromStepName('worker'); diff --git a/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart index d2d18e8..0df2956 100644 --- a/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart +++ b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart @@ -48,6 +48,7 @@ class _FakeAgUiService extends AgUiService { _FakeAgUiService() : super(apiClient: _NoopApiClient()); Completer? pendingResult; + Completer? pendingHistory; Object? nextError; @override @@ -67,6 +68,21 @@ class _FakeAgUiService extends AgUiService { return const SendMessageResult(uploadedAttachments: []); } + @override + Future 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: [], + ); + } + void emitEvent(AgUiEvent event) { onEvent(event); } @@ -189,5 +205,132 @@ void main() { expect(toolItem.errorMessage, '本次运行已失败'); expect(bloc.state.error, 'runtime execution failed'); }); + + test('text event with ui schema is rendered into chat items', () { + service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1')); + + service.emitEvent( + TextMessageEndEvent( + messageId: 'assistant-1', + answer: '这是测试回复', + role: 'assistant', + status: 'success', + uiSchema: { + 'version': '2.0', + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'children': [ + {'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'}, + ], + }, + }, + ), + ); + + service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1')); + + final messages = bloc.state.items.whereType().toList(); + final uiCards = bloc.state.items.whereType().toList(); + + expect(messages, hasLength(1)); + expect(messages.single.content, '这是测试回复'); + expect(uiCards, hasLength(1)); + expect(uiCards.single.uiSchema['root'], isA>()); + 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(); + service.pendingHistory = historyCompleter; + + final loadFuture = bloc.loadHistory(); + await Future.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: [], + ), + ); + + await loadFuture; + + final texts = bloc.state.items.whereType().toList(); + final uiCards = bloc.state.items.whereType().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() + ..complete( + HistorySnapshot( + scope: 'history_day', + threadId: 'thread-1', + day: '2026-03-24', + hasMore: false, + messages: [ + 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() + .map((item) => item.content) + .toList(), + contains('历史补偿回复'), + ); + }, + ); }); } diff --git a/apps/test/features/settings/data/models/automation_job_model_test.dart b/apps/test/features/settings/data/models/automation_job_model_test.dart index 991b941..80ac137 100644 --- a/apps/test/features/settings/data/models/automation_job_model_test.dart +++ b/apps/test/features/settings/data/models/automation_job_model_test.dart @@ -2,73 +2,29 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/features/settings/data/models/automation_job_model.dart'; void main() { - group('MessageContextConfigModel', () { - test('fromJson parses all fields correctly', () { - final json = { - 'source': 'messages', - 'window_mode': 'week', - 'window_count': 5, - }; - - final model = MessageContextConfigModel.fromJson(json); - - expect(model.source, 'messages'); - expect(model.windowMode, 'week'); - expect(model.windowCount, 5); - }); - - test('fromJson uses defaults for missing fields', () { - final model = MessageContextConfigModel.fromJson(null); - - expect(model.source, 'latest_chat'); - expect(model.windowMode, 'day'); - expect(model.windowCount, 2); - }); - - test('toJson serializes correctly', () { - final model = MessageContextConfigModel( - source: 'messages', - windowMode: 'week', - windowCount: 5, - ); - - final json = model.toJson(); - - expect(json['source'], 'messages'); - expect(json['window_mode'], 'week'); - expect(json['window_count'], 5); - }); - }); - group('AutomationJobConfigModel', () { - test('fromJson parses all fields correctly', () { + test('fromJson parses schedule correctly', () { final json = { 'input_template': 'Hello {{name}}', 'enabled_tools': ['tool1', 'tool2'], 'context': { - 'source': 'messages', - 'window_mode': 'week', + 'source': 'latest_chat', + 'window_mode': 'day', 'window_count': 5, }, + 'schedule': { + 'type': 'weekly', + 'run_at': {'hour': 9, 'minute': 30}, + 'weekdays': [1, 3, 5], + }, }; final model = AutomationJobConfigModel.fromJson(json); - expect(model.inputTemplate, 'Hello {{name}}'); - expect(model.enabledTools, ['tool1', 'tool2']); - expect(model.context.source, 'messages'); - expect(model.context.windowMode, 'week'); - expect(model.context.windowCount, 5); - }); - - test('fromJson uses defaults for null input', () { - final model = AutomationJobConfigModel.fromJson(null); - - expect(model.inputTemplate, ''); - expect(model.enabledTools, []); - expect(model.context.source, 'latest_chat'); - expect(model.context.windowMode, 'day'); - expect(model.context.windowCount, 2); + expect(model.schedule.type, 'weekly'); + expect(model.schedule.runAt.hour, 9); + expect(model.schedule.runAt.minute, 30); + expect(model.schedule.weekdays, [1, 3, 5]); }); }); @@ -79,8 +35,6 @@ void main() { 'owner_id': 'user-456', 'bootstrap_key': 'key-789', 'title': 'Daily Report', - 'schedule_type': 'DAILY', - 'run_at': '09:00:00', 'timezone': 'America/New_York', 'status': 'ACTIVE', 'is_system': false, @@ -92,6 +46,10 @@ void main() { 'window_mode': 'day', 'window_count': 2, }, + 'schedule': { + 'type': 'daily', + 'run_at': {'hour': 9, 'minute': 0}, + }, }, 'next_run_at': '2024-01-15T09:00:00Z', 'last_run_at': '2024-01-14T09:00:00Z', @@ -103,117 +61,19 @@ void main() { expect(model.id, 'job-123'); expect(model.ownerId, 'user-456'); - expect(model.bootstrapKey, 'key-789'); expect(model.title, 'Daily Report'); - expect(model.scheduleType, 'DAILY'); - expect(model.runAt, '09:00:00'); + expect(model.config.schedule.type, 'daily'); + expect(model.config.schedule.runAt.hour, 9); expect(model.timezone, 'America/New_York'); - expect(model.status, 'ACTIVE'); - expect(model.isSystem, false); - expect(model.config.inputTemplate, 'Hello'); - expect(model.config.enabledTools, ['tool1']); - expect(model.config.context.windowCount, 2); - expect(model.nextRunAt, DateTime.parse('2024-01-15T09:00:00Z')); - expect(model.lastRunAt, DateTime.parse('2024-01-14T09:00:00Z')); - expect(model.createdAt, DateTime.parse('2024-01-01T00:00:00Z')); - expect(model.updatedAt, DateTime.parse('2024-01-14T12:00:00Z')); - }); - - test('fromJson throws for missing required date fields', () { - final json = { - '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()), - ); - }); - }); - - 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; - expect(configJson.containsKey('input_template'), true); - expect(configJson.containsKey('enabled_tools'), false); - expect(configJson.containsKey('context'), false); + expect(model.isDaily, isTrue); + expect(model.isWeekly, isFalse); }); }); group('AutomationJobCreateRequest', () { - test('toJson serializes correctly', () { + test('toJson serializes schedule under config', () { final request = AutomationJobCreateRequest( title: 'New Job', - scheduleType: 'DAILY', - runAt: '10:00:00', timezone: 'UTC', status: 'ACTIVE', config: AutomationJobConfigModel( @@ -224,74 +84,47 @@ void main() { windowMode: 'day', windowCount: 2, ), + schedule: ScheduleConfigModel( + type: 'daily', + runAt: ScheduleRunAtModel(hour: 10, minute: 0), + ), ), ); final json = request.toJson(); expect(json['title'], 'New Job'); - expect(json['schedule_type'], 'DAILY'); - expect(json['run_at'], '10:00:00'); expect(json['timezone'], 'UTC'); expect(json['status'], 'ACTIVE'); - expect(json['config'], { - 'input_template': 'Hello', - 'enabled_tools': ['tool1'], - 'context': { - 'source': 'latest_chat', - 'window_mode': 'day', - 'window_count': 2, - }, + expect((json['config'] as Map)['schedule'], { + 'type': 'daily', + 'run_at': {'hour': 10, 'minute': 0}, + }); + expect(json.containsKey('run_at'), isFalse); + expect(json.containsKey('schedule_type'), isFalse); + }); + }); + + group('AutomationJobUpdateRequest', () { + test('toJson includes schedule patch in config', () { + final request = AutomationJobUpdateRequest( + config: AutomationJobConfigPatchModel( + schedule: ScheduleConfigModel( + type: 'weekly', + runAt: ScheduleRunAtModel(hour: 8, minute: 0), + weekdays: [2, 4], + ), + ), + ); + + final json = request.toJson(); + final configJson = json['config'] as Map; + + 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); - }); - }); } diff --git a/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart b/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart index 2ac2377..eb5c107 100644 --- a/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart +++ b/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart @@ -18,8 +18,6 @@ void main() { id: '1', ownerId: 'owner1', title: 'Test Job', - scheduleType: 'DAILY', - runAt: '08:00:00', timezone: 'UTC', status: 'ACTIVE', isSystem: false, @@ -31,6 +29,10 @@ void main() { windowMode: 'day', windowCount: 2, ), + schedule: ScheduleConfigModel( + type: 'daily', + runAt: ScheduleRunAtModel(hour: 8, minute: 0), + ), ), nextRunAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), diff --git a/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart b/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart index ee569e9..df068b2 100644 --- a/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart +++ b/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart @@ -21,8 +21,6 @@ void main() { id: '1', ownerId: 'owner1', title: 'Test Job', - scheduleType: 'DAILY', - runAt: '08:00:00', timezone: 'UTC', status: 'ACTIVE', isSystem: false, @@ -34,6 +32,10 @@ void main() { windowMode: 'day', windowCount: 2, ), + schedule: ScheduleConfigModel( + type: 'daily', + runAt: ScheduleRunAtModel(hour: 8, minute: 0), + ), ), nextRunAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), @@ -173,8 +175,6 @@ void main() { act: (c) => c.createJob( AutomationJobCreateRequest( title: 'New Job', - scheduleType: 'daily', - runAt: '08:00:00', timezone: 'Asia/Shanghai', status: 'active', config: AutomationJobConfigModel( @@ -185,6 +185,10 @@ void main() { windowMode: 'day', windowCount: 2, ), + schedule: ScheduleConfigModel( + type: 'daily', + runAt: ScheduleRunAtModel(hour: 8, minute: 0), + ), ), ), ), @@ -210,8 +214,6 @@ void main() { act: (c) => c.createJob( AutomationJobCreateRequest( title: 'New Job', - scheduleType: 'daily', - runAt: '08:00:00', timezone: 'Asia/Shanghai', status: 'active', config: AutomationJobConfigModel( @@ -222,6 +224,10 @@ void main() { windowMode: 'day', windowCount: 2, ), + schedule: ScheduleConfigModel( + type: 'daily', + runAt: ScheduleRunAtModel(hour: 8, minute: 0), + ), ), ), ), diff --git a/backend/alembic/versions/20260324_0001_automation_jobs_schedule_in_config.py b/backend/alembic/versions/20260324_0001_automation_jobs_schedule_in_config.py new file mode 100644 index 0000000..59f836f --- /dev/null +++ b/backend/alembic/versions/20260324_0001_automation_jobs_schedule_in_config.py @@ -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')) + """ + ) diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 1fa0092..aab1a4d 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -319,7 +319,7 @@ class AgentScopeRunner: content = user_blocks user_msg = Msg(name="user", role="user", content=content) - return [user_msg, *context_messages] + return [*context_messages, user_msg] async def _run_worker_stage( self, diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml index 1b9b013..e351332 100644 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -1,11 +1,23 @@ input_template: | - 你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具。 - 步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。 - 步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。 - 步骤3:对新增或变化的信息,调用 memory_write 执行写入。 - 步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串。 - 步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写。 - 输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。 + 你正在执行一次“自动化记忆回顾与整理”任务。 + + 任务目标: + 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。 + 2) 对已经失效、被否定或明显过期的信息执行遗忘。 + 3) 对新增且有证据支持的信息执行写入。 + 4) 严禁编造;没有证据就不要写入。 + 5) 只更新最小必要字段,避免过度覆盖。 + + 输出要求: + - 必须使用以下固定格式输出;每一行都要有: + 【记忆回顾】<一句人性化总结,说明今天主要发生了什么> + 【新增记忆】<按“X条:要点1;要点2”描述;没有则写“0条”> + 【遗忘记忆】<按“X条:要点1;要点2”描述;没有则写“0条”> + 【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明“可继续观察”> + + 表达风格: + - 语言自然、温和、可读,像助理在做每日回顾。 + - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 enabled_tools: - memory.write - memory.forget @@ -18,3 +30,4 @@ schedule: run_at: hour: 8 minute: 0 + weekdays: null diff --git a/backend/src/models/automation_jobs.py b/backend/src/models/automation_jobs.py index 86f0a1d..baef990 100644 --- a/backend/src/models/automation_jobs.py +++ b/backend/src/models/automation_jobs.py @@ -45,14 +45,6 @@ class AutomationJob(TimestampMixin, SoftDeleteMixin, Base): nullable=False, default=dict, ) - schedule_type: Mapped[ScheduleType] = mapped_column( - String(20), - nullable=False, - ) - run_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - ) next_run_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, diff --git a/backend/src/schemas/automation/__init__.py b/backend/src/schemas/automation/__init__.py index d46cb0a..c642e6d 100644 --- a/backend/src/schemas/automation/__init__.py +++ b/backend/src/schemas/automation/__init__.py @@ -5,7 +5,7 @@ from enum import Enum from uuid import UUID from core.agentscope.tools.tool_config import AgentTool -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from models.automation_jobs import AutomationJob as OrmAutomationJob from models.automation_jobs import AutomationJobStatus, ScheduleType @@ -40,6 +40,21 @@ class ScheduleConfig(BaseModel): type: ScheduleType run_at: ScheduleRunAt + weekdays: list[int] | None = None + + @model_validator(mode="after") + def validate_weekdays(self) -> "ScheduleConfig": + if self.type == ScheduleType.WEEKLY: + if not self.weekdays: + raise ValueError("weekdays is required when schedule type is weekly") + invalid = [day for day in self.weekdays if day < 1 or day > 7] + if invalid: + raise ValueError("weekdays must be within 1-7") + deduped = sorted(set(self.weekdays)) + self.weekdays = deduped + else: + self.weekdays = None + return self class RuntimeConfig(BaseModel): @@ -66,8 +81,6 @@ class AutomationJob(BaseModel): bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64) title: str = Field(..., min_length=1, max_length=255) config: AutomationJobConfig - schedule_type: ScheduleType - run_at: datetime next_run_at: datetime timezone: str = Field(default="UTC", min_length=1, max_length=50) last_run_at: datetime | None = None @@ -84,8 +97,6 @@ class AutomationJob(BaseModel): bootstrap_key=obj.bootstrap_key, title=obj.title, config=AutomationJobConfig.model_validate(obj.config or {}), - schedule_type=obj.schedule_type, - run_at=obj.run_at, next_run_at=obj.next_run_at, timezone=obj.timezone, last_run_at=obj.last_run_at, diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py index 4b3704a..a857973 100644 --- a/backend/src/v1/auth/automation_static_config.py +++ b/backend/src/v1/auth/automation_static_config.py @@ -7,12 +7,7 @@ from typing import Any import yaml -from core.agentscope.tools.tool_config import AgentTool -from models.automation_jobs import ScheduleType -from schemas.automation import ( - AutomationJobConfig, - MessageContextConfig, -) +from schemas.automation import AutomationJobConfig _CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") @@ -37,18 +32,4 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi loaded: Any = yaml.safe_load(file) or {} if not isinstance(loaded, dict): raise ValueError(f"invalid automation config format: {path}") - config = AutomationJobConfig.model_validate(loaded) - if config_name == "memory_extraction": - if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]: - raise ValueError( - "memory_extraction enabled_tools must be [memory.write, memory.forget]" - ) - if config.context != MessageContextConfig(window_count=2): - raise ValueError( - "memory_extraction context must be latest_chat/day with window_count=2" - ) - if config.schedule is None: - raise ValueError("memory_extraction schedule must be configured") - if config.schedule.type != ScheduleType.DAILY: - raise ValueError("memory_extraction schedule type must be daily") - return config + return AutomationJobConfig.model_validate(loaded) diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py index 66df06b..a6c2d22 100644 --- a/backend/src/v1/auth/registration_bootstrap.py +++ b/backend/src/v1/auth/registration_bootstrap.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, time, timedelta from typing import Protocol from uuid import UUID, uuid4 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -13,7 +13,7 @@ from core.logging import get_logger from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType from models.memories import MemoryType from models.profile import Profile -from schemas.automation import AutomationJobConfig +from schemas.automation import AutomationJobConfig, ScheduleConfig from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from schemas.user.context import parse_profile_settings from v1.auth.automation_static_config import load_static_automation_job_config @@ -44,9 +44,7 @@ class RegistrationBootstrapRepository: title: str, config: AutomationJobConfig, timezone_name: str, - run_at: datetime, next_run_at: datetime, - schedule_type: ScheduleType, ) -> bool: stmt = ( insert(AutomationJob) @@ -56,8 +54,6 @@ class RegistrationBootstrapRepository: bootstrap_key=bootstrap_key, title=title, config=config.model_dump(mode="json"), - schedule_type=schedule_type, - run_at=run_at, next_run_at=next_run_at, timezone=timezone_name, status=AutomationJobStatus.ACTIVE, @@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol): title: str, config: AutomationJobConfig, timezone_name: str, - run_at: datetime, next_run_at: datetime, - schedule_type: ScheduleType, ) -> bool: ... async def upsert_initial_memory( @@ -123,35 +117,48 @@ class SessionLike(Protocol): async def rollback(self) -> None: ... -def compute_next_local_time_utc( +def compute_first_run_at_utc( *, now_utc: datetime, timezone_name: str, - local_hour: int, - local_minute: int, - schedule_type: ScheduleType, -) -> tuple[datetime, datetime]: + schedule: ScheduleConfig, +) -> datetime: try: timezone_obj = ZoneInfo(timezone_name) except ZoneInfoNotFoundError: timezone_obj = ZoneInfo("UTC") + local_now = now_utc.astimezone(timezone_obj) - today_run_local = local_now.replace( - hour=local_hour, - minute=local_minute, - second=0, - microsecond=0, + run_clock = time( + hour=schedule.run_at.hour, + minute=schedule.run_at.minute, + tzinfo=timezone_obj, ) - run_local = ( - today_run_local - if local_now <= today_run_local - else today_run_local + timedelta(days=1) - ) - if schedule_type == ScheduleType.WEEKLY: - next_local = run_local + timedelta(weeks=1) - else: - next_local = run_local + timedelta(days=1) - return run_local.astimezone(UTC), next_local.astimezone(UTC) + + if schedule.type == ScheduleType.DAILY: + candidate_local = datetime.combine(local_now.date(), run_clock) + if candidate_local <= local_now: + candidate_local = candidate_local + timedelta(days=1) + return candidate_local.astimezone(UTC) + + weekdays = schedule.weekdays or [] + if not weekdays: + raise ValueError("weekly schedule requires weekdays") + + normalized_weekdays = sorted(set(weekdays)) + for day_offset in range(0, 8): + candidate_day = local_now.date() + timedelta(days=day_offset) + if candidate_day.isoweekday() not in normalized_weekdays: + continue + candidate_local = datetime.combine(candidate_day, run_clock) + if candidate_local > local_now: + return candidate_local.astimezone(UTC) + + fallback_day = local_now.date() + timedelta(days=7) + while fallback_day.isoweekday() not in normalized_weekdays: + fallback_day = fallback_day + timedelta(days=1) + fallback_local = datetime.combine(fallback_day, run_clock) + return fallback_local.astimezone(UTC) class RegistrationAutomationBootstrapService: @@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService: raise ValueError( f"bootstrap job {bootstrap_key} has no schedule configured" ) - run_at, next_run_at = compute_next_local_time_utc( + next_run_at = compute_first_run_at_utc( now_utc=datetime.now(UTC), timezone_name=timezone_name, - local_hour=schedule.run_at.hour, - local_minute=schedule.run_at.minute, - schedule_type=schedule.type, + schedule=schedule, ) inserted = ( await self._repository.insert_bootstrap_automation_job_if_absent( @@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService: title=str(definition["title"]), config=job_config, timezone_name=timezone_name, - run_at=run_at, next_run_at=next_run_at, - schedule_type=schedule.type, ) ) inserted_any = inserted_any or inserted diff --git a/backend/src/v1/automation_jobs/repository.py b/backend/src/v1/automation_jobs/repository.py index 0eed681..194251b 100644 --- a/backend/src/v1/automation_jobs/repository.py +++ b/backend/src/v1/automation_jobs/repository.py @@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from core.db.base_repository import BaseRepository from models.agent_chat_session import AgentChatSession, SessionType from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType +from schemas.automation import AutomationJobConfig, ScheduleConfig if TYPE_CHECKING: from v1.automation_jobs.schemas import ( @@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]): except ZoneInfoNotFoundError: return ZoneInfo("UTC") - def _compute_initial_next_run_at( + def _compute_next_run_at( self, *, - run_at: time, + schedule: ScheduleConfig, timezone_str: str, now_utc: datetime, - schedule_type: ScheduleType, ) -> datetime: tz = self._resolve_timezone(timezone_str) local_now = now_utc.astimezone(tz) - run_at_local = datetime.combine(local_now.date(), run_at, tz) - if run_at_local.tzinfo is None: - run_at_local = run_at_local.replace(tzinfo=tz) - next_run_at = run_at_local - if next_run_at <= local_now: - if schedule_type == ScheduleType.DAILY: - next_run_at = next_run_at + timedelta(days=1) - else: - next_run_at = next_run_at + timedelta(weeks=1) - return next_run_at.astimezone(timezone.utc) + run_clock = time( + hour=schedule.run_at.hour, + minute=schedule.run_at.minute, + tzinfo=tz, + ) + + if schedule.type == ScheduleType.DAILY: + candidate_local = datetime.combine(local_now.date(), run_clock) + if candidate_local <= local_now: + candidate_local = candidate_local + timedelta(days=1) + return candidate_local.astimezone(timezone.utc) + + weekdays = schedule.weekdays or [] + if not weekdays: + raise ValueError("weekly schedule requires weekdays") + + normalized_weekdays = sorted(set(weekdays)) + for day_offset in range(0, 8): + candidate_day = local_now.date() + timedelta(days=day_offset) + if candidate_day.isoweekday() not in normalized_weekdays: + continue + candidate_local = datetime.combine(candidate_day, run_clock) + if candidate_local > local_now: + return candidate_local.astimezone(timezone.utc) + + fallback_day = local_now.date() + timedelta(days=7) + while fallback_day.isoweekday() not in normalized_weekdays: + fallback_day = fallback_day + timedelta(days=1) + fallback_local = datetime.combine(fallback_day, run_clock) + return fallback_local.astimezone(timezone.utc) async def create( self, @@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]): data: AutomationJobCreateRequest, ) -> AutomationJob: now_utc = datetime.now(tz=timezone.utc) - timezone_obj = self._resolve_timezone(data.timezone) - local_now = now_utc.astimezone(timezone_obj) - date_ref = local_now.date() - local_dt = datetime.combine(date_ref, data.run_at, timezone_obj) - run_at_datetime = local_dt.astimezone(timezone.utc) - next_run_at = self._compute_initial_next_run_at( - run_at=data.run_at, + schedule = data.config.schedule + if schedule is None: + raise ValueError("config.schedule is required") + + next_run_at = self._compute_next_run_at( + schedule=schedule, timezone_str=data.timezone, now_utc=now_utc, - schedule_type=data.schedule_type, ) new_job = AutomationJob( @@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]): created_by=owner_id, bootstrap_key=None, title=data.title, - schedule_type=data.schedule_type, - run_at=run_at_datetime, timezone=data.timezone, status=data.status, config=data.config.model_dump(mode="json"), @@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]): data: AutomationJobUpdateRequest, ) -> AutomationJob | None: update_values: dict[str, object] = {} - existing_job: AutomationJob | None = None + existing_job = await self.get_by_id(job_id) + if existing_job is None: + return None if data.title is not None: update_values["title"] = data.title - if data.schedule_type is not None: - update_values["schedule_type"] = data.schedule_type - should_recompute_schedule = ( - data.run_at is not None - or data.schedule_type is not None - or data.timezone is not None - ) - if should_recompute_schedule: - now_utc = datetime.now(tz=timezone.utc) - if existing_job is None: - existing_job = await self.get_by_id(job_id) - if existing_job is None: - return None - - effective_timezone = data.timezone or existing_job.timezone - effective_timezone_obj = self._resolve_timezone(effective_timezone) - effective_schedule_type = data.schedule_type or existing_job.schedule_type - - if data.run_at is not None: - effective_run_at = data.run_at - else: - existing_timezone_obj = self._resolve_timezone(existing_job.timezone) - effective_run_at = ( - existing_job.run_at.astimezone(existing_timezone_obj) - .time() - .replace(microsecond=0) - ) - - local_now = now_utc.astimezone(effective_timezone_obj) - local_dt = datetime.combine( - local_now.date(), - effective_run_at, - effective_timezone_obj, - ) - update_values["run_at"] = local_dt.astimezone(timezone.utc) - update_values["next_run_at"] = self._compute_initial_next_run_at( - run_at=effective_run_at, - timezone_str=effective_timezone, - now_utc=now_utc, - schedule_type=effective_schedule_type, - ) if data.timezone is not None: update_values["timezone"] = data.timezone if data.status is not None: update_values["status"] = data.status + + merged_config_raw: dict[str, object] = dict(existing_job.config or {}) if data.config is not None: - if existing_job is None: - existing_job = await self.get_by_id(job_id) - if existing_job is None: - return None - merged_config = { - **existing_job.config, + merged_config_raw = { + **merged_config_raw, **data.config.model_dump(mode="json", exclude_unset=True), } - update_values["config"] = merged_config + normalized_config = AutomationJobConfig.model_validate(merged_config_raw) + update_values["config"] = normalized_config.model_dump(mode="json") + else: + normalized_config = AutomationJobConfig.model_validate(merged_config_raw) + + schedule_changed = data.config is not None and ( + "schedule" in data.config.model_dump(mode="json", exclude_unset=True) + ) + if data.timezone is not None or schedule_changed: + if normalized_config.schedule is None: + raise ValueError("config.schedule is required") + effective_timezone = data.timezone or existing_job.timezone + update_values["next_run_at"] = self._compute_next_run_at( + schedule=normalized_config.schedule, + timezone_str=effective_timezone, + now_utc=datetime.now(tz=timezone.utc), + ) if not update_values: - return await self.get_by_id(job_id) + return existing_job return await self.update_by_id(job_id, update_values) diff --git a/backend/src/v1/automation_jobs/schemas.py b/backend/src/v1/automation_jobs/schemas.py index a723f1d..27aaa3e 100644 --- a/backend/src/v1/automation_jobs/schemas.py +++ b/backend/src/v1/automation_jobs/schemas.py @@ -1,14 +1,14 @@ from __future__ import annotations -from datetime import datetime, time +from datetime import datetime from typing import Self from uuid import UUID from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from models.automation_jobs import AutomationJob as OrmAutomationJob -from models.automation_jobs import AutomationJobStatus, ScheduleType +from models.automation_jobs import AutomationJobStatus from schemas.automation import AutomationJobConfig @@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel): owner_id: UUID bootstrap_key: str | None = None title: str - schedule_type: ScheduleType - run_at: time timezone: str status: AutomationJobStatus is_system: bool @@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel): owner_id=obj.owner_id, bootstrap_key=obj.bootstrap_key, title=obj.title, - schedule_type=obj.schedule_type, - run_at=obj.run_at.time(), timezone=obj.timezone, status=obj.status, is_system=obj.bootstrap_key is not None, @@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") title: str = Field(..., min_length=1, max_length=255) - schedule_type: ScheduleType - run_at: time = Field(..., description="Local time in HH:MM:SS format") timezone: str = Field(..., min_length=1, max_length=50) status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE) config: AutomationJobConfig + @model_validator(mode="after") + def validate_schedule_required(self) -> "AutomationJobCreateRequest": + if self.config.schedule is None: + raise ValueError("config.schedule is required") + return self + @field_validator("timezone") @classmethod def validate_timezone(cls, value: str) -> str: @@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") title: str | None = Field(None, min_length=1, max_length=255) - schedule_type: ScheduleType | None = None - run_at: time | None = None timezone: str | None = Field(None, min_length=1, max_length=50) status: AutomationJobStatus | None = None config: AutomationJobConfig | None = None diff --git a/backend/src/v1/automation_jobs/service.py b/backend/src/v1/automation_jobs/service.py index 9a6ec39..a8acdc1 100644 --- a/backend/src/v1/automation_jobs/service.py +++ b/backend/src/v1/automation_jobs/service.py @@ -1,9 +1,10 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta, timezone from typing import TYPE_CHECKING, Protocol from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import HTTPException, status from models.automation_jobs import ScheduleType @@ -11,6 +12,7 @@ from schemas.automation import ( AutomationJob as AutomationJobSchema, MessageContextConfig, RuntimeConfig, + ScheduleConfig, ) from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError @@ -68,15 +70,53 @@ class DispatchFn(Protocol): def _compute_next_run_at( *, - current_next_run_at: datetime, + schedule: ScheduleConfig, + timezone_str: str, now_utc: datetime, - schedule_type: ScheduleType, ) -> datetime: - delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7) - next_run_at = current_next_run_at - while next_run_at <= now_utc: - next_run_at = next_run_at + delta - return next_run_at + try: + tz = ZoneInfo(timezone_str) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + + local_now = now_utc.astimezone(tz) + run_clock = time( + hour=schedule.run_at.hour, + minute=schedule.run_at.minute, + tzinfo=tz, + ) + + if schedule.type == ScheduleType.DAILY: + candidate_local = datetime.combine(local_now.date(), run_clock) + if candidate_local <= local_now: + candidate_local = candidate_local + timedelta(days=1) + return candidate_local.astimezone(timezone.utc) + + weekdays = schedule.weekdays or [] + if not weekdays: + raise ValueError("weekly schedule requires weekdays") + + normalized_weekdays = sorted(set(weekdays)) + for day_offset in range(0, 8): + candidate_day = local_now.date() + timedelta(days=day_offset) + if candidate_day.isoweekday() not in normalized_weekdays: + continue + candidate_local = datetime.combine(candidate_day, run_clock) + if candidate_local > local_now: + return candidate_local.astimezone(timezone.utc) + + fallback_day = local_now.date() + timedelta(days=7) + while fallback_day.isoweekday() not in normalized_weekdays: + fallback_day = fallback_day + timedelta(days=1) + fallback_local = datetime.combine(fallback_day, run_clock) + return fallback_local.astimezone(timezone.utc) + + +def _ensure_schedule(job: AutomationJobSchema) -> ScheduleConfig: + schedule = job.config.schedule + if schedule is None: + raise ValueError(f"job {job.id} config.schedule is missing") + return schedule @dataclass(slots=True) @@ -129,9 +169,9 @@ class AutomationJobsService: await self._repository.update_job_schedule( job_id=job.id, next_run_at=_compute_next_run_at( - current_next_run_at=job.next_run_at, + schedule=_ensure_schedule(job), + timezone_str=job.timezone, now_utc=now_utc, - schedule_type=job.schedule_type, ), last_run_at=now_utc, ) diff --git a/backend/tests/integration/v1/automation_jobs/test_router.py b/backend/tests/integration/v1/automation_jobs/test_router.py index 4a1d95f..55725bf 100644 --- a/backend/tests/integration/v1/automation_jobs/test_router.py +++ b/backend/tests/integration/v1/automation_jobs/test_router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, time, timezone +from datetime import datetime, timezone from uuid import UUID, uuid4 from fastapi.testclient import TestClient @@ -29,13 +29,24 @@ def _make_job_response( id=job_id or uuid4(), owner_id=owner_id or uuid4(), title=overrides.get("title", "Test Job"), - schedule_type=overrides.get("schedule_type", "daily"), - run_at=overrides.get("run_at", time(9, 0, 0)), timezone=overrides.get("timezone", "Asia/Shanghai"), status=overrides.get("status", "active"), is_system=overrides.get("is_system", False), config=overrides.get( - "config", {"input_template": "Hello", "enabled_tools": [], "context": {}} + "config", + { + "input_template": "Hello", + "enabled_tools": [], + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, + }, ), next_run_at=overrides.get("next_run_at", now), created_at=overrides.get("created_at", now), @@ -104,13 +115,19 @@ def test_create_automation_job_requires_auth() -> None: "/api/v1/automation-jobs", json={ "title": "New Job", - "schedule_type": "daily", - "run_at": "09:00:00", "timezone": "Asia/Shanghai", "config": { "input_template": "Hello", "enabled_tools": [], - "context": {}, + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, }, }, ) @@ -140,14 +157,20 @@ def test_create_automation_job_succeeds() -> None: "/api/v1/automation-jobs", json={ "title": "New Job", - "schedule_type": "daily", - "run_at": "09:00:00", "timezone": "Asia/Shanghai", "status": "active", "config": { "input_template": "Hello", "enabled_tools": [], - "context": {}, + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, }, }, ) @@ -178,14 +201,20 @@ def test_create_automation_job_respects_limit() -> None: "/api/v1/automation-jobs", json={ "title": "New Job", - "schedule_type": "daily", - "run_at": "09:00:00", "timezone": "Asia/Shanghai", "status": "active", "config": { "input_template": "Hello", "enabled_tools": [], - "context": {}, + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, }, }, ) @@ -204,7 +233,7 @@ def test_get_automation_job_requires_auth() -> None: def test_get_automation_job_returns_job() -> None: user_id = uuid4() job_id = uuid4() - job = _make_job_response(id=job_id, owner_id=user_id) + job = _make_job_response(job_id=job_id, owner_id=user_id) captured_job_id = job_id captured_owner_id = user_id @@ -266,7 +295,11 @@ def test_update_automation_job_requires_auth() -> None: def test_update_automation_job_succeeds() -> None: user_id = uuid4() job_id = uuid4() - updated_job = _make_job_response(id=job_id, owner_id=user_id, title="Updated Title") + updated_job = _make_job_response( + job_id=job_id, + owner_id=user_id, + title="Updated Title", + ) class FakeService: async def update( diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index 7994284..3144850 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -114,6 +114,28 @@ def test_build_router_messages_skips_injection_when_context_last_is_user() -> No assert msg.content == existing_context[i].content +def test_build_router_messages_appends_user_input_to_context_tail() -> None: + runner = AgentScopeRunner() + run_input = _run_input() + + from agentscope.message import Msg + + existing_context = [ + Msg(name="assistant", role="assistant", content="上一轮回复"), + Msg(name="tool", role="assistant", content="工具结果"), + ] + + messages = runner._build_router_messages( + context_messages=existing_context, + run_input=run_input, + ) + + assert len(messages) == len(existing_context) + 1 + assert messages[-1].role == "user" + assert messages[-1].content == "hello" + assert messages[0].content == "上一轮回复" + + def test_build_model_omits_none_generate_kwargs( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/backend/tests/unit/core/automation/test_scheduler.py b/backend/tests/unit/core/automation/test_scheduler.py index a60d53e..1f83c04 100644 --- a/backend/tests/unit/core/automation/test_scheduler.py +++ b/backend/tests/unit/core/automation/test_scheduler.py @@ -8,6 +8,8 @@ import pytest from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType from schemas.automation import ( RuntimeConfig, + ScheduleConfig, + ScheduleRunAt, ) from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at @@ -50,7 +52,6 @@ def _make_orm_job( *, job_id: UUID | None = None, owner_id: UUID | None = None, - schedule_type: ScheduleType = ScheduleType.DAILY, next_run_at: datetime | None = None, ) -> OrmAutomationJob: now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) @@ -66,9 +67,14 @@ def _make_orm_job( "window_count": 2, }, "input_template": "auto input: {date}", + "schedule": { + "type": "daily", + "run_at": { + "hour": 8, + "minute": 0, + }, + }, }, - schedule_type=schedule_type, - run_at=now - timedelta(hours=1), next_run_at=next_run_at or now - timedelta(minutes=1), timezone="UTC", last_run_at=None, @@ -108,12 +114,14 @@ async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None def test_compute_next_run_at_daily() -> None: now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) - current = datetime(2026, 3, 19, 11, 0, tzinfo=timezone.utc) computed = _compute_next_run_at( - current_next_run_at=current, + schedule=ScheduleConfig( + type=ScheduleType.DAILY, + run_at=ScheduleRunAt(hour=11, minute=0), + ), + timezone_str="UTC", now_utc=now, - schedule_type=ScheduleType.DAILY, ) assert computed == datetime(2026, 3, 20, 11, 0, tzinfo=timezone.utc) @@ -121,12 +129,15 @@ def test_compute_next_run_at_daily() -> None: def test_compute_next_run_at_weekly() -> None: now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc) - current = datetime(2026, 3, 10, 11, 0, tzinfo=timezone.utc) computed = _compute_next_run_at( - current_next_run_at=current, + schedule=ScheduleConfig( + type=ScheduleType.WEEKLY, + run_at=ScheduleRunAt(hour=11, minute=0), + weekdays=[2], + ), + timezone_str="UTC", now_utc=now, - schedule_type=ScheduleType.WEEKLY, ) assert computed == datetime(2026, 3, 24, 11, 0, tzinfo=timezone.utc) diff --git a/backend/tests/unit/core/config/test_memory_automation_static_config.py b/backend/tests/unit/core/config/test_memory_automation_static_config.py index 9ae5d3e..8686162 100644 --- a/backend/tests/unit/core/config/test_memory_automation_static_config.py +++ b/backend/tests/unit/core/config/test_memory_automation_static_config.py @@ -13,7 +13,7 @@ def test_memory_automation_static_config_contract() -> None: "memory.forget", ] assert config.input_template is not None - assert "提取" in config.input_template + assert "回顾" in config.input_template assert "遗忘" in config.input_template assert config.schedule is not None assert config.schedule.type.value == "daily" diff --git a/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py index 1a90027..819f859 100644 --- a/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py +++ b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py @@ -8,38 +8,39 @@ import pytest from models.automation_jobs import ScheduleType from v1.auth.registration_bootstrap import ( - compute_next_local_time_utc, + compute_first_run_at_utc, ) +from schemas.automation import ScheduleConfig, ScheduleRunAt -def test_compute_next_local_time_utc_from_asia_shanghai() -> None: +def test_compute_first_run_at_utc_from_asia_shanghai() -> None: now_utc = datetime(2026, 3, 23, 0, 30, tzinfo=timezone.utc) - run_at, next_run_at = compute_next_local_time_utc( + first_run_at = compute_first_run_at_utc( now_utc=now_utc, timezone_name="Asia/Shanghai", - local_hour=8, - local_minute=0, - schedule_type=ScheduleType.DAILY, + schedule=ScheduleConfig( + type=ScheduleType.DAILY, + run_at=ScheduleRunAt(hour=8, minute=0), + ), ) - assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) - assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc) + assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) -def test_compute_next_local_time_utc_rolls_to_next_day_when_passed() -> None: +def test_compute_first_run_at_utc_rolls_to_next_day_when_passed() -> None: now_utc = datetime(2026, 3, 23, 2, 30, tzinfo=timezone.utc) - run_at, next_run_at = compute_next_local_time_utc( + first_run_at = compute_first_run_at_utc( now_utc=now_utc, timezone_name="Asia/Shanghai", - local_hour=8, - local_minute=0, - schedule_type=ScheduleType.DAILY, + schedule=ScheduleConfig( + type=ScheduleType.DAILY, + run_at=ScheduleRunAt(hour=8, minute=0), + ), ) - assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) - assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc) + assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) @pytest.mark.asyncio diff --git a/backend/tests/unit/v1/automation_jobs/test_repository.py b/backend/tests/unit/v1/automation_jobs/test_repository.py index 399744b..d1277b5 100644 --- a/backend/tests/unit/v1/automation_jobs/test_repository.py +++ b/backend/tests/unit/v1/automation_jobs/test_repository.py @@ -1,21 +1,22 @@ -from datetime import datetime, time, timezone from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from models.automation_jobs import AutomationJobStatus, ScheduleType -from v1.automation_jobs.repository import AutomationJobsRepository -from v1.automation_jobs.schemas import ( - AutomationJobCreateRequest, - AutomationJobUpdateRequest, -) from schemas.automation import ( AgentTool, AutomationJobConfig, ContextSource, ContextWindowMode, MessageContextConfig, + ScheduleConfig, + ScheduleRunAt, +) +from v1.automation_jobs.repository import AutomationJobsRepository +from v1.automation_jobs.schemas import ( + AutomationJobCreateRequest, + AutomationJobUpdateRequest, ) @@ -28,14 +29,16 @@ def _make_config() -> AutomationJobConfig: window_mode=ContextWindowMode.DAY, window_count=2, ), + schedule=ScheduleConfig( + type=ScheduleType.DAILY, + run_at=ScheduleRunAt(hour=9, minute=0), + ), ) def _make_create_request() -> AutomationJobCreateRequest: return AutomationJobCreateRequest( title="Test Job", - schedule_type=ScheduleType.DAILY, - run_at=time(9, 0, 0), timezone="Asia/Shanghai", status=AutomationJobStatus.ACTIVE, config=_make_config(), @@ -57,9 +60,6 @@ async def test_list_by_owner_returns_jobs() -> None: assert result == [job_one, job_two] session.execute.assert_awaited_once() - call_args = session.execute.call_args - stmt = call_args[0][0] - assert "owner_id" in str(stmt) @pytest.mark.asyncio @@ -74,16 +74,10 @@ async def test_count_user_jobs_counts_non_bootstrap_jobs() -> None: result = await repository.count_user_jobs(owner_id) assert result == 3 - session.execute.assert_awaited_once() - call_args = session.execute.call_args - stmt = call_args[0][0] - stmt_str = str(stmt) - assert "bootstrap_key" in stmt_str - assert "IS NULL" in stmt_str or "is_(None)" in stmt_str.lower() @pytest.mark.asyncio -async def test_create_sets_bootstrap_key_to_none() -> None: +async def test_create_sets_fields_and_next_run_at() -> None: session = AsyncMock() session.add = MagicMock() repository = AutomationJobsRepository(session) @@ -93,67 +87,13 @@ async def test_create_sets_bootstrap_key_to_none() -> None: await repository.create(owner_id, data) session.add.assert_called_once() - call_args = session.add.call_args[0][0] - assert call_args.bootstrap_key is None - session.flush.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_create_sets_correct_fields() -> None: - session = AsyncMock() - session.add = MagicMock() - repository = AutomationJobsRepository(session) - owner_id = uuid4() - data = _make_create_request() - - await repository.create(owner_id, data) - call_args = session.add.call_args[0][0] assert call_args.owner_id == owner_id + assert call_args.bootstrap_key is None assert call_args.title == data.title - assert call_args.schedule_type == data.schedule_type assert call_args.timezone == data.timezone assert call_args.status == data.status - - -@pytest.mark.asyncio -async def test_update_returns_updated_job() -> None: - session = AsyncMock() - repository = AutomationJobsRepository(session) - job_id = uuid4() - existing_job = MagicMock() - existing_job.schedule_type = ScheduleType.DAILY - existing_job.config = {"input_template": "Old"} - updated_job = MagicMock() - execute_result = MagicMock() - execute_result.scalar_one_or_none.return_value = updated_job - session.execute.return_value = execute_result - - data = AutomationJobUpdateRequest(title="Updated Title") - result = await repository.update(job_id, data) - - assert result is updated_job - session.flush.assert_awaited() - - -@pytest.mark.asyncio -async def test_update_merges_config() -> None: - session = AsyncMock() - repository = AutomationJobsRepository(session) - job_id = uuid4() - existing_job = MagicMock() - existing_job.schedule_type = ScheduleType.DAILY - existing_job.config = {"input_template": "Old", "enabled_tools": []} - execute_result = MagicMock() - execute_result.scalar_one_or_none.return_value = existing_job - session.execute.return_value = execute_result - - data = AutomationJobUpdateRequest( - config={"input_template": "New", "context": {"source": "latest_chat"}} - ) - await repository.update(job_id, data) - - session.flush.assert_awaited() + assert call_args.next_run_at is not None @pytest.mark.asyncio @@ -161,9 +101,8 @@ async def test_update_returns_none_when_job_not_found() -> None: session = AsyncMock() repository = AutomationJobsRepository(session) job_id = uuid4() - execute_result = MagicMock() - execute_result.scalar_one_or_none.return_value = None - session.execute.return_value = execute_result + + repository.get_by_id = AsyncMock(return_value=None) data = AutomationJobUpdateRequest(title="Updated Title") result = await repository.update(job_id, data) @@ -171,6 +110,50 @@ async def test_update_returns_none_when_job_not_found() -> None: assert result is None +@pytest.mark.asyncio +async def test_update_merges_config_and_recomputes_next_run() -> None: + session = AsyncMock() + repository = AutomationJobsRepository(session) + job_id = uuid4() + existing_job = MagicMock() + existing_job.timezone = "UTC" + existing_job.config = { + "input_template": "Old", + "enabled_tools": ["memory.write"], + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 8, "minute": 0}, + }, + } + + repository.get_by_id = AsyncMock(return_value=existing_job) + repository.update_by_id = AsyncMock(return_value=existing_job) + + data = AutomationJobUpdateRequest( + config=AutomationJobConfig( + enabled_tools=[AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET], + schedule=ScheduleConfig( + type=ScheduleType.WEEKLY, + run_at=ScheduleRunAt(hour=10, minute=30), + weekdays=[2, 5], + ), + ), + ) + result = await repository.update(job_id, data) + + assert result is not None + update_values = repository.update_by_id.call_args[0][1] + assert "config" in update_values + assert "next_run_at" in update_values + enabled_tools = update_values["config"]["enabled_tools"] + assert isinstance(enabled_tools[0], str) + + @pytest.mark.asyncio async def test_soft_delete_calls_soft_delete_by_id() -> None: session = AsyncMock() @@ -197,91 +180,3 @@ async def test_list_due_jobs_filters_by_active_status() -> None: await repository.list_due_jobs(now_utc=MagicMock(), limit=10) session.execute.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_create_stores_run_at_as_timezone_aware() -> None: - session = AsyncMock() - session.add = MagicMock() - repository = AutomationJobsRepository(session) - owner_id = uuid4() - data = _make_create_request() - - await repository.create(owner_id, data) - - call_args = session.add.call_args[0][0] - assert call_args.run_at.tzinfo is not None, "run_at should be timezone-aware" - - -@pytest.mark.asyncio -async def test_update_run_at_with_timezone_none_uses_existing_timezone() -> None: - session = AsyncMock() - repository = AutomationJobsRepository(session) - job_id = uuid4() - existing_job = MagicMock() - existing_job.schedule_type = ScheduleType.DAILY - existing_job.timezone = "America/New_York" - existing_job.config = {} - existing_job.run_at = None - execute_result = MagicMock() - execute_result.scalar_one_or_none.return_value = existing_job - session.execute.return_value = execute_result - - repository.update_by_id = AsyncMock(return_value=existing_job) - - data = AutomationJobUpdateRequest(run_at=time(14, 30, 0)) - result = await repository.update(job_id, data) - - assert result is not None - update_values = repository.update_by_id.call_args[0][1] - assert "run_at" in update_values - assert "next_run_at" in update_values - - -@pytest.mark.asyncio -async def test_update_schedule_type_recomputes_next_run_at() -> None: - session = AsyncMock() - repository = AutomationJobsRepository(session) - job_id = uuid4() - existing_job = MagicMock() - existing_job.schedule_type = ScheduleType.DAILY - existing_job.timezone = "UTC" - existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) - existing_job.config = {} - - repository.get_by_id = AsyncMock(return_value=existing_job) - repository.update_by_id = AsyncMock(return_value=existing_job) - - data = AutomationJobUpdateRequest(schedule_type=ScheduleType.WEEKLY) - result = await repository.update(job_id, data) - - assert result is not None - update_values = repository.update_by_id.call_args[0][1] - assert update_values["schedule_type"] == ScheduleType.WEEKLY - assert "run_at" in update_values - assert "next_run_at" in update_values - - -@pytest.mark.asyncio -async def test_update_config_serializes_enum_values_to_json() -> None: - session = AsyncMock() - repository = AutomationJobsRepository(session) - job_id = uuid4() - existing_job = MagicMock() - existing_job.schedule_type = ScheduleType.DAILY - existing_job.timezone = "UTC" - existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) - existing_job.config = {"input_template": "Old"} - - repository.get_by_id = AsyncMock(return_value=existing_job) - repository.update_by_id = AsyncMock(return_value=existing_job) - - data = AutomationJobUpdateRequest( - config={"enabled_tools": [AgentTool.MEMORY_WRITE]}, - ) - result = await repository.update(job_id, data) - - assert result is not None - update_values = repository.update_by_id.call_args[0][1] - enabled_tools = update_values["config"]["enabled_tools"] - assert isinstance(enabled_tools[0], str) diff --git a/backend/tests/unit/v1/automation_jobs/test_schemas.py b/backend/tests/unit/v1/automation_jobs/test_schemas.py index 3b3725e..ddb34e1 100644 --- a/backend/tests/unit/v1/automation_jobs/test_schemas.py +++ b/backend/tests/unit/v1/automation_jobs/test_schemas.py @@ -1,246 +1,107 @@ -import pytest from datetime import datetime from unittest.mock import MagicMock from uuid import uuid4 +import pytest from pydantic import ValidationError +from schemas.automation import AgentTool, AutomationJobConfig from v1.automation_jobs.schemas import ( AutomationJobCreateRequest, - AutomationJobUpdateRequest, AutomationJobResponse, + AutomationJobUpdateRequest, ) -from schemas.automation import AgentTool, AutomationJobConfig -class TestIsSystemProperty: - def test_is_system_true_when_bootstrap_key_present(self): - mock_orm_job = MagicMock() - mock_orm_job.id = uuid4() - mock_orm_job.owner_id = uuid4() - mock_orm_job.bootstrap_key = "memory_extraction" - mock_orm_job.title = "Test Job" - mock_orm_job.schedule_type = "daily" - mock_orm_job.run_at = datetime.now() - mock_orm_job.config = { - "input_template": "Hello", - "enabled_tools": [], - "context": {}, - } - mock_orm_job.schedule_type = "daily" - mock_orm_job.status = "active" - mock_orm_job.timezone = "Asia/Shanghai" - mock_orm_job.next_run_at = datetime.now() - mock_orm_job.last_run_at = None - mock_orm_job.created_at = datetime.now() - mock_orm_job.updated_at = datetime.now() - mock_orm_job.deleted_at = None - - resp = AutomationJobResponse.from_orm(mock_orm_job) - assert resp.is_system is True - - def test_is_system_false_when_bootstrap_key_none(self): - mock_orm_job = MagicMock() - mock_orm_job.id = uuid4() - mock_orm_job.owner_id = uuid4() - mock_orm_job.bootstrap_key = None - mock_orm_job.title = "Test Job" - mock_orm_job.schedule_type = "daily" - mock_orm_job.run_at = datetime.now() - mock_orm_job.config = { - "input_template": "Hello", - "enabled_tools": [], - "context": {}, - } - mock_orm_job.schedule_type = "daily" - mock_orm_job.status = "active" - mock_orm_job.timezone = "Asia/Shanghai" - mock_orm_job.next_run_at = datetime.now() - mock_orm_job.last_run_at = None - mock_orm_job.created_at = datetime.now() - mock_orm_job.updated_at = datetime.now() - mock_orm_job.deleted_at = None - - resp = AutomationJobResponse.from_orm(mock_orm_job) - assert resp.is_system is False +def _mock_orm_job() -> MagicMock: + mock_orm_job = MagicMock() + mock_orm_job.id = uuid4() + mock_orm_job.owner_id = uuid4() + mock_orm_job.bootstrap_key = "memory_extraction" + mock_orm_job.title = "Test Job" + mock_orm_job.config = { + "input_template": "Hello", + "enabled_tools": ["memory.write", "memory.forget"], + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 8, "minute": 0}, + }, + } + mock_orm_job.status = "active" + mock_orm_job.timezone = "Asia/Shanghai" + mock_orm_job.next_run_at = datetime.now() + mock_orm_job.last_run_at = None + mock_orm_job.created_at = datetime.now() + mock_orm_job.updated_at = datetime.now() + return mock_orm_job -class TestFromOrm: - def test_run_at_converted_from_datetime_to_time(self): - run_at_datetime = datetime(2024, 6, 15, 14, 30, 0) - mock_orm_job = MagicMock() - mock_orm_job.id = uuid4() - mock_orm_job.owner_id = uuid4() - mock_orm_job.bootstrap_key = None - mock_orm_job.title = "Test Job" - mock_orm_job.schedule_type = "daily" - mock_orm_job.run_at = run_at_datetime - mock_orm_job.config = { - "input_template": "Hello", - "enabled_tools": [], - "context": {}, - } - mock_orm_job.schedule_type = "daily" - mock_orm_job.status = "active" - mock_orm_job.timezone = "Asia/Shanghai" - mock_orm_job.next_run_at = datetime.now() - mock_orm_job.last_run_at = None - mock_orm_job.created_at = datetime.now() - mock_orm_job.updated_at = datetime.now() - mock_orm_job.deleted_at = None - - resp = AutomationJobResponse.from_orm(mock_orm_job) - assert resp.run_at == run_at_datetime.time() - - def test_config_deserialized(self): - config = { - "input_template": "Test template", - "enabled_tools": [AgentTool.MEMORY_WRITE], - "context": { - "source": "latest_chat", - "window_mode": "day", - "window_count": 5, - }, - } - mock_orm_job = MagicMock() - mock_orm_job.id = uuid4() - mock_orm_job.owner_id = uuid4() - mock_orm_job.bootstrap_key = None - mock_orm_job.title = "Test Job" - mock_orm_job.schedule_type = "daily" - mock_orm_job.run_at = datetime.now() - mock_orm_job.config = config - mock_orm_job.schedule_type = "daily" - mock_orm_job.status = "active" - mock_orm_job.timezone = "Asia/Shanghai" - mock_orm_job.next_run_at = datetime.now() - mock_orm_job.last_run_at = None - mock_orm_job.created_at = datetime.now() - mock_orm_job.updated_at = datetime.now() - mock_orm_job.deleted_at = None - - resp = AutomationJobResponse.from_orm(mock_orm_job) - assert resp.config.input_template == "Test template" - assert resp.config.enabled_tools == [AgentTool.MEMORY_WRITE] - assert resp.config.context.window_count == 5 - - def test_is_system_derived_from_bootstrap_key(self): - mock_orm_job = MagicMock() - mock_orm_job.id = uuid4() - mock_orm_job.owner_id = uuid4() - mock_orm_job.bootstrap_key = "system_bootstrap" - mock_orm_job.title = "Test Job" - mock_orm_job.schedule_type = "daily" - mock_orm_job.run_at = datetime.now() - mock_orm_job.config = { - "input_template": "Hello", - "enabled_tools": [], - "context": {}, - } - mock_orm_job.schedule_type = "daily" - mock_orm_job.status = "active" - mock_orm_job.timezone = "UTC" - mock_orm_job.next_run_at = datetime.now() - mock_orm_job.last_run_at = None - mock_orm_job.created_at = datetime.now() - mock_orm_job.updated_at = datetime.now() - mock_orm_job.deleted_at = None - - resp = AutomationJobResponse.from_orm(mock_orm_job) - assert resp.is_system is True - assert resp.bootstrap_key == "system_bootstrap" +def test_response_is_system_true_when_bootstrap_key_present() -> None: + resp = AutomationJobResponse.from_orm(_mock_orm_job()) + assert resp.is_system is True -class TestTimezoneValidation: - def test_valid_timezone(self): - request = AutomationJobCreateRequest.model_validate( +def test_response_parses_schedule_from_config() -> None: + resp = AutomationJobResponse.from_orm(_mock_orm_job()) + assert resp.config.schedule is not None + assert resp.config.schedule.type.value == "daily" + assert resp.config.schedule.run_at.hour == 8 + + +def test_create_request_requires_config_schedule() -> None: + with pytest.raises(ValidationError): + AutomationJobCreateRequest.model_validate( { "title": "Test Job", - "schedule_type": "daily", - "run_at": "09:00:00", "timezone": "Asia/Shanghai", "config": { "input_template": "Hello", - "enabled_tools": [], - "context": { - "source": "latest_chat", - "window_mode": "day", - "window_count": 2, - }, }, } ) - assert request.timezone == "Asia/Shanghai" - - def test_invalid_timezone(self): - with pytest.raises(ValidationError) as exc_info: - AutomationJobCreateRequest.model_validate( - { - "title": "Test Job", - "schedule_type": "daily", - "run_at": "09:00:00", - "timezone": "Invalid/Timezone", - "config": { - "input_template": "Hello", - "enabled_tools": [], - "context": { - "source": "latest_chat", - "window_mode": "day", - "window_count": 2, - }, - }, - } - ) - assert "timezone must be a valid IANA timezone" in str(exc_info.value) - - def test_update_valid_timezone(self): - request = AutomationJobUpdateRequest.model_validate( - { - "timezone": "America/New_York", - } - ) - assert request.timezone == "America/New_York" - - def test_update_invalid_timezone(self): - with pytest.raises(ValidationError) as exc_info: - AutomationJobUpdateRequest.model_validate( - { - "timezone": "Invalid/Timezone", - } - ) - assert "timezone must be a valid IANA timezone" in str(exc_info.value) - - def test_update_none_timezone_allowed(self): - request = AutomationJobUpdateRequest.model_validate( - { - "timezone": None, - } - ) - assert request.timezone is None -class TestAutomationJobConfigPatch: - def test_all_fields_optional(self): - patch = AutomationJobConfig.model_validate({}) - assert patch.input_template is None - assert patch.enabled_tools is None - assert patch.context is None +def test_create_request_valid_timezone() -> None: + request = AutomationJobCreateRequest.model_validate( + { + "title": "Test Job", + "timezone": "Asia/Shanghai", + "config": { + "input_template": "Hello", + "enabled_tools": ["memory.write"], + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, + }, + } + ) + assert request.timezone == "Asia/Shanghai" - def test_partial_input_template(self): - patch = AutomationJobConfig.model_validate( - { - "input_template": "Updated template", - } - ) - assert patch.input_template == "Updated template" - assert patch.enabled_tools is None - assert patch.context is None - def test_extra_fields_forbidden(self): - with pytest.raises(ValidationError): - AutomationJobConfig.model_validate( - { - "input_template": "Test", - "unknown_field": "value", - } - ) +def test_update_timezone_validation() -> None: + request = AutomationJobUpdateRequest.model_validate( + {"timezone": "America/New_York"} + ) + assert request.timezone == "America/New_York" + + with pytest.raises(ValidationError): + AutomationJobUpdateRequest.model_validate({"timezone": "Invalid/Timezone"}) + + +def test_config_patch_still_allows_partial_payload() -> None: + patch = AutomationJobConfig.model_validate( + {"enabled_tools": [AgentTool.MEMORY_WRITE]} + ) + assert patch.input_template is None + assert patch.enabled_tools == [AgentTool.MEMORY_WRITE] diff --git a/backend/tests/unit/v1/automation_jobs/test_service.py b/backend/tests/unit/v1/automation_jobs/test_service.py index 941b5f4..1fb9881 100644 --- a/backend/tests/unit/v1/automation_jobs/test_service.py +++ b/backend/tests/unit/v1/automation_jobs/test_service.py @@ -1,6 +1,6 @@ -from datetime import datetime, time, timezone +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from fastapi import HTTPException @@ -23,6 +23,8 @@ from schemas.automation import ( ContextSource, ContextWindowMode, MessageContextConfig, + ScheduleConfig, + ScheduleRunAt, ) @@ -35,14 +37,16 @@ def _make_config() -> AutomationJobConfig: window_mode=ContextWindowMode.DAY, window_count=2, ), + schedule=ScheduleConfig( + type=ScheduleType.DAILY, + run_at=ScheduleRunAt(hour=9, minute=0), + ), ) def _make_create_request() -> AutomationJobCreateRequest: return AutomationJobCreateRequest( title="Test Job", - schedule_type=ScheduleType.DAILY, - run_at=time(9, 0, 0), timezone="Asia/Shanghai", status=AutomationJobStatus.ACTIVE, config=_make_config(), @@ -50,18 +54,28 @@ def _make_create_request() -> AutomationJobCreateRequest: def _make_job( - owner_id: MagicMock | None = None, bootstrap_key: str | None = None + owner_id: UUID | None = None, bootstrap_key: str | None = None ) -> MagicMock: job = MagicMock() job.id = uuid4() job.owner_id = owner_id or uuid4() job.bootstrap_key = bootstrap_key job.title = "Test Job" - job.schedule_type = ScheduleType.DAILY - job.run_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc) job.timezone = "Asia/Shanghai" job.status = AutomationJobStatus.ACTIVE - job.config = {"input_template": "Hello"} + job.config = { + "input_template": "Hello", + "enabled_tools": ["memory.write"], + "context": { + "source": "latest_chat", + "window_mode": "day", + "window_count": 2, + }, + "schedule": { + "type": "daily", + "run_at": {"hour": 9, "minute": 0}, + }, + } job.next_run_at = datetime(2024, 1, 2, 9, 0, 0, tzinfo=timezone.utc) job.last_run_at = None job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc) @@ -210,7 +224,9 @@ class TestUpdate: with pytest.raises(AutomationJobNotFound): await service.update( - job_id, owner_id, AutomationJobUpdateRequest(title="New") + job_id, + owner_id, + AutomationJobUpdateRequest(title="New", timezone="UTC"), ) @pytest.mark.asyncio @@ -225,7 +241,9 @@ class TestUpdate: with pytest.raises(AutomationJobNotFound): await service.update( - job.id, owner_id, AutomationJobUpdateRequest(title="New") + job.id, + owner_id, + AutomationJobUpdateRequest(title="New", timezone="UTC"), ) @pytest.mark.asyncio @@ -239,7 +257,9 @@ class TestUpdate: with pytest.raises(SystemJobModificationForbidden): await service.update( - job.id, owner_id, AutomationJobUpdateRequest(title="New") + job.id, + owner_id, + AutomationJobUpdateRequest(title="New", timezone="UTC"), ) repository.update.assert_not_called() @@ -257,12 +277,15 @@ class TestUpdate: repository.update.return_value = updated_job result = await service.update( - job.id, owner_id, AutomationJobUpdateRequest(title="Updated Title") + job.id, + owner_id, + AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"), ) assert result.title == "Updated Title" repository.update.assert_awaited_once_with( - job.id, AutomationJobUpdateRequest(title="Updated Title") + job.id, + AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"), ) session.commit.assert_awaited_once() @@ -278,7 +301,9 @@ class TestUpdate: with pytest.raises(AutomationJobNotFound): await service.update( - job.id, owner_id, AutomationJobUpdateRequest(title="New") + job.id, + owner_id, + AutomationJobUpdateRequest(title="New", timezone="UTC"), ) @pytest.mark.asyncio @@ -293,7 +318,9 @@ class TestUpdate: with pytest.raises(HTTPException) as exc: await service.update( - job.id, owner_id, AutomationJobUpdateRequest(title="New") + job.id, + owner_id, + AutomationJobUpdateRequest(title="New", timezone="UTC"), ) assert exc.value.status_code == 503 diff --git a/docs/protocols/models/automation-jobs.md b/docs/protocols/models/automation-jobs.md new file mode 100644 index 0000000..14f8a92 --- /dev/null +++ b/docs/protocols/models/automation-jobs.md @@ -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.