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