diff --git a/data-agent-backend/pom.xml b/data-agent-backend/pom.xml index e5a1e53..9b79bd0 100644 --- a/data-agent-backend/pom.xml +++ b/data-agent-backend/pom.xml @@ -123,7 +123,6 @@ io.agentscope agentscope-extensions-nacos-skill ${agentscope.version} - true diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/DataAgentApplication.java b/data-agent-backend/src/main/java/io/github/malonetalk/DataAgentApplication.java index 4b5d007..6de766c 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/DataAgentApplication.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/DataAgentApplication.java @@ -19,7 +19,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class DataAgentApplication { diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/agent/AgentService.java b/data-agent-backend/src/main/java/io/github/malonetalk/agent/AgentService.java index c5a8a6d..a227872 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/agent/AgentService.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/agent/AgentService.java @@ -79,33 +79,27 @@ public void init() { } public Flux chatStream( - int userId, - String sessionId, - String userInput, - List toolResults, - Integer datasourceId) { - return Flux.defer( - () -> streamAgent(userId, sessionId, userInput, toolResults, datasourceId)) - .onErrorResume(this::toErrorEvent); + ToolCallContext context, List toolResults) { + Flux stream = Flux.defer(() -> streamAgent(context, toolResults)); + if (!context.scheduled()) { + return stream.onErrorResume(this::toErrorEvent); + } + return stream; } private Flux streamAgent( - int userId, - String sessionId, - String userInput, - List toolResults, - Integer datasourceId) { - // 首次访问声明归属;已绑定会被 INSERT IGNORE 忽略 - sessionService.bindUserSession(userId, sessionId); - if (datasourceId != null) { - datasourceService.bindSessionDatasource(sessionId, datasourceId); + ToolCallContext context, List toolResults) { + if (context.userId() != null) { + sessionService.bindUserSession(context.userId(), context.sessionId()); + } + if (context.datasourceId() != null) { + datasourceService.bindSessionDatasource(context.sessionId(), context.datasourceId()); } - ReActAgent agent = - createAgent(ToolCallContext.builder().sessionId(sessionId).userId(userId).build()); + ReActAgent agent = createAgent(context); - Session session = sessionService.getOrCreateSession(sessionId); - agent.loadIfExists(session, sessionId); - Msg userMsg = buildUserMessage(userInput, toolResults); + Session session = sessionService.getOrCreateSession(context.sessionId()); + agent.loadIfExists(session, context.sessionId()); + Msg userMsg = buildUserMessage(context.userInput(), toolResults); StreamOptions streamOptions = StreamOptions.builder() @@ -128,13 +122,13 @@ private Flux streamAgent( signalType -> log.info( "SSE chat stream finished: sessionId={}, signal={}", - sessionId, + context.sessionId(), signalType)) .subscribeOn(Schedulers.boundedElastic()) .flatMapIterable(eventConverter::map) .doFinally( signalType -> { - agent.saveTo(session, sessionId); + agent.saveTo(session, context.sessionId()); MDC.remove(TraceIdFilter.TRACE_ID_MDC_KEY); }); } diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/agent/ToolCallContext.java b/data-agent-backend/src/main/java/io/github/malonetalk/agent/ToolCallContext.java index b36c62a..8470ea5 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/agent/ToolCallContext.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/agent/ToolCallContext.java @@ -20,4 +20,9 @@ import lombok.Builder; @Builder -public record ToolCallContext(String sessionId, Integer userId) {} +public record ToolCallContext( + String sessionId, + String userInput, + Integer datasourceId, + Integer userId, + boolean scheduled) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/AskUserTool.java b/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/AskUserTool.java index faf47d8..04ae45c 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/AskUserTool.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/AskUserTool.java @@ -20,6 +20,7 @@ import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; import io.agentscope.core.tool.ToolSuspendException; +import io.github.malonetalk.agent.ToolCallContext; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -34,8 +35,13 @@ public class AskUserTool implements MarkAgentTool { + " confirmation. Execution resumes after the user responds.") public String askUser( @ToolParam(name = "question", description = "The question to ask the user.") - String question) { + String question, + ToolCallContext ctx) { log.info("Agent asks user: {}", question); + if (ctx.scheduled()) { + return "Cannot ask the user during this run. Explain what information is missing and" + + " stop."; + } throw new ToolSuspendException(question); } } diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/AgentController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AgentController.java index cf1c63c..df2bc5b 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/AgentController.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AgentController.java @@ -22,6 +22,7 @@ import io.agentscope.core.message.Msg; import io.github.malonetalk.agent.AgentService; import io.github.malonetalk.agent.SessionService; +import io.github.malonetalk.agent.ToolCallContext; import io.github.malonetalk.common.ErrorCode; import io.github.malonetalk.common.Result; import io.github.malonetalk.common.UserContext; @@ -57,16 +58,17 @@ public class AgentController { @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux> chatStream( @Valid @RequestBody ChatRequest request) { - // 在 servlet 线程抓 userId,避免 Reactor 线程拿不到 ThreadLocal int userId = UserContext.require().userId(); log.info("SSE chat stream started: sessionId={}, userId={}", request.sessionId(), userId); + ToolCallContext context = + ToolCallContext.builder() + .sessionId(request.sessionId()) + .userInput(request.message()) + .datasourceId(request.datasourceId()) + .userId(userId) + .build(); return agentService - .chatStream( - userId, - request.sessionId(), - request.message(), - request.toolResults(), - request.datasourceId()) + .chatStream(context, request.toolResults()) .map( event -> ServerSentEvent.builder() diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/ScheduledAgentTaskController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/ScheduledAgentTaskController.java new file mode 100644 index 0000000..a4e00e5 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/ScheduledAgentTaskController.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.controller; + +import io.github.malonetalk.common.Result; +import io.github.malonetalk.dto.ScheduledAgentTaskRequest; +import io.github.malonetalk.dto.ScheduledAgentTaskResponse; +import io.github.malonetalk.service.ScheduledAgentTaskService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/scheduled-agent-tasks") +public class ScheduledAgentTaskController { + + private final ScheduledAgentTaskService taskService; + + @PostMapping + public Result create(@Valid @RequestBody ScheduledAgentTaskRequest request) { + taskService.create(request); + return Result.success(); + } + + @PutMapping("/{id}") + public Result update( + @PathVariable @Positive(message = "id must be positive.") Integer id, + @Valid @RequestBody ScheduledAgentTaskRequest request) { + taskService.update(id, request); + return Result.success(); + } + + @DeleteMapping("/{id}") + public Result delete( + @PathVariable @Positive(message = "id must be positive.") Integer id) { + taskService.delete(id); + return Result.success(); + } + + @GetMapping + public Result> listAll() { + return Result.success(taskService.listAll()); + } + + @PatchMapping("/{id}/enabled") + public Result setEnabled( + @PathVariable @Positive(message = "id must be positive.") Integer id, + @Valid @RequestBody EnabledRequest request) { + taskService.setEnabled(id, request.enabled()); + return Result.success(); + } + + @PostMapping("/{id}/run") + public Result runNow( + @PathVariable @Positive(message = "id must be positive.") Integer id) { + return Result.success(taskService.runNow(id)); + } + + private record EnabledRequest(@NotNull(message = "enabled cannot be null.") Boolean enabled) {} +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskRequest.java new file mode 100644 index 0000000..76ce4d1 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskRequest.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +import io.github.malonetalk.enums.ScheduledAgentScheduleType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ScheduledAgentTaskRequest( + @NotBlank(message = "name cannot be blank.") String name, + @NotBlank(message = "prompt cannot be blank.") String prompt, + @NotNull(message = "scheduleType cannot be null.") ScheduledAgentScheduleType scheduleType, + @NotBlank(message = "scheduleExpr cannot be blank.") String scheduleExpr, + Boolean enabled) {} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskResponse.java new file mode 100644 index 0000000..e5c9e40 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ScheduledAgentTaskResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +import io.github.malonetalk.entity.ScheduledAgentTask; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record ScheduledAgentTaskResponse( + Integer id, + String name, + String prompt, + String scheduleType, + String scheduleExpr, + Boolean enabled, + Boolean running, + LocalDateTime nextRunAt, + String lastStatus, + String lastError) { + + public static ScheduledAgentTaskResponse from(ScheduledAgentTask task) { + return ScheduledAgentTaskResponse.builder() + .id(task.getId()) + .name(task.getName()) + .prompt(task.getPrompt()) + .scheduleType(task.getScheduleType()) + .scheduleExpr(task.getScheduleExpr()) + .enabled(task.getEnabled()) + .running(task.getRunning()) + .nextRunAt(task.getNextRunAt()) + .lastStatus(task.getLastStatus()) + .lastError(task.getLastError()) + .build(); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/entity/ScheduledAgentTask.java b/data-agent-backend/src/main/java/io/github/malonetalk/entity/ScheduledAgentTask.java new file mode 100644 index 0000000..2c94e5c --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/entity/ScheduledAgentTask.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.entity; + +import java.time.LocalDateTime; +import lombok.Data; + +@Data +public class ScheduledAgentTask { + + private Integer id; + private String name; + private String prompt; + private String scheduleType; + private String scheduleExpr; + private Boolean enabled; + private Boolean running; + private LocalDateTime nextRunAt; + private LocalDateTime lastRunAt; + private String lastStatus; + private String lastError; +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/enums/ScheduledAgentScheduleType.java b/data-agent-backend/src/main/java/io/github/malonetalk/enums/ScheduledAgentScheduleType.java new file mode 100644 index 0000000..d41562a --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/enums/ScheduledAgentScheduleType.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.enums; + +public enum ScheduledAgentScheduleType { + DAILY, + INTERVAL, + CRON +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/mapper/ScheduledAgentTaskMapper.java b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/ScheduledAgentTaskMapper.java new file mode 100644 index 0000000..8243cd5 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/ScheduledAgentTaskMapper.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.mapper; + +import io.github.malonetalk.entity.ScheduledAgentTask; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ScheduledAgentTaskMapper { + + int insert(ScheduledAgentTask task); + + int update(ScheduledAgentTask task); + + int deleteById(@Param("id") Integer id); + + int updateEnabled( + @Param("id") Integer id, + @Param("enabled") boolean enabled, + @Param("nextRunAt") LocalDateTime nextRunAt); + + ScheduledAgentTask selectById(@Param("id") Integer id); + + List selectAll(); + + List findDueTaskIds(@Param("now") LocalDateTime now, @Param("limit") int limit); + + int lockForRun( + @Param("id") Integer id, + @Param("now") LocalDateTime now, + @Param("lockUntil") LocalDateTime lockUntil, + @Param("lockOwner") String lockOwner); + + int lockForManualRun( + @Param("id") Integer id, + @Param("now") LocalDateTime now, + @Param("lockUntil") LocalDateTime lockUntil, + @Param("lockOwner") String lockOwner); + + int finishRun( + @Param("id") Integer id, + @Param("lockOwner") String lockOwner, + @Param("nextRunAt") LocalDateTime nextRunAt, + @Param("finishedAt") LocalDateTime finishedAt, + @Param("lastStatus") String lastStatus, + @Param("lastError") String lastError); +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskDispatcher.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskDispatcher.java new file mode 100644 index 0000000..b235731 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskDispatcher.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.mapper.ScheduledAgentTaskMapper; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty( + name = "data-agent.schedule.dispatcher", + havingValue = "database", + matchIfMissing = true) +@RequiredArgsConstructor +class DatabaseScheduledAgentTaskDispatcher { + + private final ScheduledAgentTaskMapper taskMapper; + private final DatabaseScheduledAgentTaskRunner taskRunner; + + @Value("${data-agent.schedule.batch-size}") + private int batchSize; + + @Scheduled(fixedDelayString = "${data-agent.schedule.dispatch-delay-ms}") + public void dispatchDueTasks() { + for (Integer taskId : taskMapper.findDueTaskIds(LocalDateTime.now(), batchSize)) { + taskRunner.runDue(taskId); + } + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskRunner.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskRunner.java new file mode 100644 index 0000000..bfd90e2 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskRunner.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.entity.ScheduledAgentTask; +import io.github.malonetalk.exception.BusinessException; +import io.github.malonetalk.mapper.ScheduledAgentTaskMapper; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +class DatabaseScheduledAgentTaskRunner { + + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String STATUS_FAILED = "FAILED"; + private static final String REJECTED_MESSAGE = "Task executor rejected the run."; + + private final ScheduledAgentTaskMapper taskMapper; + private final ScheduledAgentTaskExecutor taskExecutor; + + @Value("${data-agent.schedule.lock-duration}") + private Duration lockDuration; + + void runDue(Integer taskId) { + ClaimedRun claimedRun = claim(taskId, false); + if (claimedRun != null) { + execute(claimedRun); + } + } + + boolean runNow(Integer taskId) { + ClaimedRun claimedRun = claim(taskId, true); + return claimedRun != null && execute(claimedRun); + } + + private boolean execute(ClaimedRun claimedRun) { + CompletableFuture result; + try { + result = taskExecutor.execute(claimedRun.task()); + } catch (RejectedExecutionException e) { + finish(claimedRun, STATUS_FAILED, REJECTED_MESSAGE); + log.warn("Scheduled agent task executor rejected taskId={}", claimedRun.task().getId()); + throw BusinessException.of(ErrorCode.DATA_CONFLICT, REJECTED_MESSAGE); + } + + result.whenComplete((ignored, throwable) -> finish(claimedRun, throwable)); + return true; + } + + private ClaimedRun claim(Integer taskId, boolean manual) { + LocalDateTime startedAt = LocalDateTime.now(); + String lockOwner = UUID.randomUUID().toString(); + LocalDateTime lockUntil = startedAt.plus(lockDuration); + int updated = + manual + ? taskMapper.lockForManualRun(taskId, startedAt, lockUntil, lockOwner) + : taskMapper.lockForRun(taskId, startedAt, lockUntil, lockOwner); + if (updated == 0) { + return null; + } + + ScheduledAgentTask task = taskMapper.selectById(taskId); + return ClaimedRun.builder().task(task).lockOwner(lockOwner).force(manual).build(); + } + + private void finish(ClaimedRun claimedRun, Throwable throwable) { + if (throwable != null) { + String lastError = rootCauseMessage(throwable); + log.error( + "Scheduled agent task failed: taskId={}", claimedRun.task().getId(), throwable); + finish(claimedRun, STATUS_FAILED, lastError); + return; + } + + finish(claimedRun, STATUS_SUCCESS, null); + } + + private void finish(ClaimedRun claimedRun, String lastStatus, String lastError) { + ScheduledAgentTask task = claimedRun.task(); + LocalDateTime finishedAt = LocalDateTime.now(); + LocalDateTime currentNextRunAt = task.getNextRunAt(); + LocalDateTime nextRunAt = + claimedRun.force() + && currentNextRunAt != null + && currentNextRunAt.isAfter(finishedAt) + ? currentNextRunAt + : ScheduledAgentScheduleCalculator.nextRunAfter( + task.getScheduleType(), task.getScheduleExpr(), finishedAt); + int updated = + taskMapper.finishRun( + task.getId(), + claimedRun.lockOwner(), + nextRunAt, + finishedAt, + lastStatus, + lastError); + if (updated == 0) { + log.warn("Scheduled agent task lock changed before finish: taskId={}", task.getId()); + } + } + + private String rootCauseMessage(Throwable throwable) { + Throwable rootCause = throwable; + while (rootCause.getCause() != null) { + rootCause = rootCause.getCause(); + } + String message = rootCause.getMessage(); + return rootCause.getClass().getSimpleName() + + (message == null || message.isBlank() ? "" : ": " + message); + } + + @Builder + private record ClaimedRun(ScheduledAgentTask task, String lockOwner, boolean force) {} +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskService.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskService.java new file mode 100644 index 0000000..a836daa --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/DatabaseScheduledAgentTaskService.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.dto.ScheduledAgentTaskRequest; +import io.github.malonetalk.dto.ScheduledAgentTaskResponse; +import io.github.malonetalk.entity.ScheduledAgentTask; +import io.github.malonetalk.exception.BusinessException; +import io.github.malonetalk.mapper.ScheduledAgentTaskMapper; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class DatabaseScheduledAgentTaskService implements ScheduledAgentTaskService { + + private final ScheduledAgentTaskMapper taskMapper; + private final DatabaseScheduledAgentTaskRunner taskRunner; + + @Override + public void create(ScheduledAgentTaskRequest request) { + ScheduledAgentTask task = buildTask(request); + taskMapper.insert(task); + } + + @Override + public void update(Integer id, ScheduledAgentTaskRequest request) { + ScheduledAgentTask task = buildTask(request); + task.setId(id); + if (taskMapper.update(task) == 0) { + throw BusinessException.of( + ErrorCode.DATA_CONFLICT, + "Scheduled task does not exist or is running: id=" + id); + } + } + + @Override + public void delete(Integer id) { + if (taskMapper.deleteById(id) == 0) { + throw notFound(id); + } + } + + @Override + public void setEnabled(Integer id, boolean enabled) { + LocalDateTime nextRunAt = null; + if (enabled) { + ScheduledAgentTask task = requireTask(id); + nextRunAt = + ScheduledAgentScheduleCalculator.nextRunAfter( + task.getScheduleType(), task.getScheduleExpr(), LocalDateTime.now()); + } + updateEnabled(id, enabled, nextRunAt); + } + + @Override + public List listAll() { + return taskMapper.selectAll().stream().map(ScheduledAgentTaskResponse::from).toList(); + } + + @Override + public boolean runNow(Integer taskId) { + return taskRunner.runNow(taskId); + } + + private void updateEnabled(Integer id, boolean enabled, LocalDateTime nextRunAt) { + if (taskMapper.updateEnabled(id, enabled, nextRunAt) == 0) { + throw notFound(id); + } + } + + private ScheduledAgentTask requireTask(Integer id) { + ScheduledAgentTask task = taskMapper.selectById(id); + if (task == null) { + throw notFound(id); + } + return task; + } + + private ScheduledAgentTask buildTask(ScheduledAgentTaskRequest request) { + ScheduledAgentTask task = new ScheduledAgentTask(); + task.setName(request.name().trim()); + task.setPrompt(request.prompt().trim()); + task.setScheduleType(request.scheduleType().name()); + task.setScheduleExpr(request.scheduleExpr().trim()); + task.setEnabled(request.enabled() == null || request.enabled()); + task.setNextRunAt( + Boolean.TRUE.equals(task.getEnabled()) + ? ScheduledAgentScheduleCalculator.nextRunAfter( + task.getScheduleType(), task.getScheduleExpr(), LocalDateTime.now()) + : null); + return task; + } + + private BusinessException notFound(Integer id) { + return BusinessException.of( + ErrorCode.RESOURCE_NOT_FOUND, "Scheduled task does not exist: id=" + id); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentScheduleCalculator.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentScheduleCalculator.java new file mode 100644 index 0000000..25030f5 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentScheduleCalculator.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.enums.ScheduledAgentScheduleType; +import io.github.malonetalk.exception.BusinessException; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import org.springframework.scheduling.support.CronExpression; + +public final class ScheduledAgentScheduleCalculator { + + private ScheduledAgentScheduleCalculator() {} + + public static LocalDateTime nextRunAfter(String type, String expr, LocalDateTime after) { + return switch (ScheduledAgentScheduleType.valueOf(type)) { + case DAILY -> nextDaily(expr, after); + case INTERVAL -> after.plus(parsePositiveDuration(expr)); + case CRON -> nextCron(expr, after); + }; + } + + private static LocalDateTime nextDaily(String expr, LocalDateTime after) { + LocalTime time = parseDailyTime(expr); + LocalDateTime next = after.toLocalDate().atTime(time); + return next.isAfter(after) ? next : next.plusDays(1); + } + + private static LocalDateTime nextCron(String expr, LocalDateTime after) { + LocalDateTime next; + try { + next = CronExpression.parse(requireScheduleExpr(expr)).next(after); + } catch (IllegalArgumentException e) { + throw invalidSchedule("Invalid cron expression: " + expr); + } + if (next == null) { + throw invalidSchedule("Cron expression has no next run time."); + } + return next; + } + + private static LocalTime parseDailyTime(String expr) { + try { + return LocalTime.parse(requireScheduleExpr(expr)); + } catch (DateTimeException e) { + throw invalidSchedule("Invalid daily schedule time: " + expr); + } + } + + private static Duration parsePositiveDuration(String expr) { + Duration duration; + try { + duration = Duration.parse(requireScheduleExpr(expr)); + } catch (DateTimeException e) { + throw invalidSchedule("Invalid interval duration: " + expr); + } + if (duration.isZero() || duration.isNegative()) { + throw invalidSchedule("Interval duration must be positive."); + } + return duration; + } + + private static String requireScheduleExpr(String expr) { + if (expr == null || expr.isBlank()) { + throw invalidSchedule("scheduleExpr cannot be blank."); + } + return expr.trim(); + } + + private static BusinessException invalidSchedule(String message) { + return BusinessException.of(ErrorCode.BAD_REQUEST, message); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskExecutor.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskExecutor.java new file mode 100644 index 0000000..475a5d4 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskExecutor.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.agent.AgentService; +import io.github.malonetalk.agent.ToolCallContext; +import io.github.malonetalk.entity.ScheduledAgentTask; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ScheduledAgentTaskExecutor { + + private final AgentService agentService; + private final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + @Value("${data-agent.schedule.executor.core-pool-size}") + private int corePoolSize; + + @Value("${data-agent.schedule.executor.max-pool-size}") + private int maxPoolSize; + + @Value("${data-agent.schedule.executor.queue-capacity}") + private int queueCapacity; + + @PostConstruct + void initExecutor() { + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("scheduled-agent-task-"); + executor.initialize(); + } + + public CompletableFuture execute(ScheduledAgentTask task) { + return CompletableFuture.runAsync(() -> runAgent(task), executor); + } + + private void runAgent(ScheduledAgentTask task) { + agentService + .chatStream( + ToolCallContext.builder() + .sessionId( + "scheduled-task-" + task.getId() + "-" + UUID.randomUUID()) + .userInput(task.getPrompt()) + .scheduled(true) + .build(), + null) + .blockLast(); + } + + @PreDestroy + void shutdown() { + executor.shutdown(); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskService.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskService.java new file mode 100644 index 0000000..383d696 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/ScheduledAgentTaskService.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.dto.ScheduledAgentTaskRequest; +import io.github.malonetalk.dto.ScheduledAgentTaskResponse; +import java.util.List; + +public interface ScheduledAgentTaskService { + + void create(ScheduledAgentTaskRequest request); + + void update(Integer id, ScheduledAgentTaskRequest request); + + void delete(Integer id); + + void setEnabled(Integer id, boolean enabled); + + List listAll(); + + boolean runNow(Integer taskId); +} diff --git a/data-agent-backend/src/main/resources/application.properties b/data-agent-backend/src/main/resources/application.properties index e7038ac..63a7a95 100644 --- a/data-agent-backend/src/main/resources/application.properties +++ b/data-agent-backend/src/main/resources/application.properties @@ -29,6 +29,15 @@ io.github.malonetalk.model.thinking-enabled=${IO_GITHUB_MALONETALK_MODEL_THINKIN spring.config.import=classpath:skill.properties +# Scheduled agent task configuration +data-agent.schedule.dispatcher=${DATA_AGENT_SCHEDULE_DISPATCHER:database} +data-agent.schedule.dispatch-delay-ms=${DATA_AGENT_SCHEDULE_DISPATCH_DELAY_MS:10000} +data-agent.schedule.batch-size=${DATA_AGENT_SCHEDULE_BATCH_SIZE:20} +data-agent.schedule.lock-duration=${DATA_AGENT_SCHEDULE_LOCK_DURATION:PT30M} +data-agent.schedule.executor.core-pool-size=${DATA_AGENT_SCHEDULE_EXECUTOR_CORE_POOL_SIZE:3} +data-agent.schedule.executor.max-pool-size=${DATA_AGENT_SCHEDULE_EXECUTOR_MAX_POOL_SIZE:3} +data-agent.schedule.executor.queue-capacity=${DATA_AGENT_SCHEDULE_EXECUTOR_QUEUE_CAPACITY:20} + # Auth Configuration (login round): all env-driven, no secrets committed. # JWT secret must be >= 32 bytes in production; blank => in-memory random key (dev only, tokens invalidated on restart). jwt.secret=${JWT_SECRET:} diff --git a/data-agent-backend/src/main/resources/mapper/ScheduledAgentTaskMapper.xml b/data-agent-backend/src/main/resources/mapper/ScheduledAgentTaskMapper.xml new file mode 100644 index 0000000..0dd4368 --- /dev/null +++ b/data-agent-backend/src/main/resources/mapper/ScheduledAgentTaskMapper.xml @@ -0,0 +1,109 @@ + + + + + + id, + name, + prompt, + schedule_type, + schedule_expr, + enabled, + (lock_until IS NOT NULL AND lock_until > NOW()) AS running, + next_run_at, + last_status, + last_error + + + + INSERT INTO scheduled_agent_task ( + name, prompt, schedule_type, schedule_expr, + enabled, next_run_at + ) VALUES ( + #{name}, #{prompt}, #{scheduleType}, #{scheduleExpr}, + #{enabled}, #{nextRunAt} + ) + + + + UPDATE scheduled_agent_task + SET name = #{name}, + prompt = #{prompt}, + schedule_type = #{scheduleType}, + schedule_expr = #{scheduleExpr}, + enabled = #{enabled}, + next_run_at = #{nextRunAt}, + update_time = NOW() + WHERE id = #{id} + AND (lock_until IS NULL OR lock_until < NOW()) + + + + DELETE FROM scheduled_agent_task + WHERE id = #{id} + + + + UPDATE scheduled_agent_task + SET enabled = #{enabled}, + next_run_at = #{nextRunAt}, + update_time = NOW() + WHERE id = #{id} + + + + + + + + + + UPDATE scheduled_agent_task + SET lock_until = #{lockUntil}, + lock_owner = #{lockOwner}, + update_time = #{now} + WHERE id = #{id} + AND (lock_until IS NULL OR lock_until < #{now}) + AND enabled = 1 + AND next_run_at <= #{now} + + + + UPDATE scheduled_agent_task + SET lock_until = #{lockUntil}, + lock_owner = #{lockOwner}, + update_time = #{now} + WHERE id = #{id} + AND (lock_until IS NULL OR lock_until < #{now}) + + + + UPDATE scheduled_agent_task + SET lock_until = NULL, + lock_owner = NULL, + next_run_at = #{nextRunAt}, + last_run_at = #{finishedAt}, + last_status = #{lastStatus}, + last_error = #{lastError}, + update_time = #{finishedAt} + WHERE id = #{id} + AND lock_owner = #{lockOwner} + + diff --git a/data-agent-frontend/src/api/scheduledTask.ts b/data-agent-frontend/src/api/scheduledTask.ts new file mode 100644 index 0000000..61355e0 --- /dev/null +++ b/data-agent-frontend/src/api/scheduledTask.ts @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import request from './request'; +import type { ApiResponse } from './request'; + +export type ScheduleType = 'DAILY' | 'INTERVAL' | 'CRON'; + +export interface ScheduledTaskRequest { + name: string; + prompt: string; + scheduleType: ScheduleType; + scheduleExpr: string; + enabled?: boolean; +} + +export interface ScheduledTaskResponse { + id: number; + name: string; + prompt: string; + scheduleType: ScheduleType; + scheduleExpr: string; + enabled: boolean; + running?: boolean; + nextRunAt?: string | null; + lastStatus?: string | null; + lastError?: string | null; +} + +export function listScheduledTasks() { + return request.get>('/scheduled-agent-tasks'); +} + +export function createScheduledTask(data: ScheduledTaskRequest) { + return request.post>('/scheduled-agent-tasks', data); +} + +export function updateScheduledTask(id: number, data: ScheduledTaskRequest) { + return request.put>(`/scheduled-agent-tasks/${id}`, data); +} + +export function deleteScheduledTask(id: number) { + return request.delete>(`/scheduled-agent-tasks/${id}`); +} + +export function setScheduledTaskEnabled(id: number, enabled: boolean) { + return request.patch>(`/scheduled-agent-tasks/${id}/enabled`, { enabled }); +} + +export function runScheduledTask(id: number) { + return request.post>(`/scheduled-agent-tasks/${id}/run`); +} diff --git a/data-agent-frontend/src/components/layout/AppSidebar.vue b/data-agent-frontend/src/components/layout/AppSidebar.vue index 7b10d20..1a1b7e8 100644 --- a/data-agent-frontend/src/components/layout/AppSidebar.vue +++ b/data-agent-frontend/src/components/layout/AppSidebar.vue @@ -30,6 +30,7 @@ { path: '/semantic', title: '语义管理', icon: 'Collection' }, { path: '/report', title: '报告管理', icon: 'Document' }, { path: '/metric', title: '指标口径管理', icon: 'DataLine' }, + { path: '/scheduled-task', title: '定时任务', icon: 'Timer' }, { path: '/sys-user', title: '用户管理', icon: 'User' }, { path: '/sys-role', title: '角色管理', icon: 'Avatar' }, ]; diff --git a/data-agent-frontend/src/router/index.ts b/data-agent-frontend/src/router/index.ts index acf89ee..6ad4ab2 100644 --- a/data-agent-frontend/src/router/index.ts +++ b/data-agent-frontend/src/router/index.ts @@ -67,6 +67,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/metric/MetricManage.vue'), meta: { title: '指标口径管理' }, }, + { + path: '/scheduled-task', + name: 'ScheduledTaskManage', + component: () => import('@/views/scheduled-task/ScheduledTaskManage.vue'), + meta: { title: '定时任务' }, + }, { path: '/sys-user', name: 'UserManage', diff --git a/data-agent-frontend/src/views/scheduled-task/ScheduledTaskManage.vue b/data-agent-frontend/src/views/scheduled-task/ScheduledTaskManage.vue new file mode 100644 index 0000000..196edcc --- /dev/null +++ b/data-agent-frontend/src/views/scheduled-task/ScheduledTaskManage.vue @@ -0,0 +1,322 @@ + + + + + + + diff --git a/sql/data_source.sql b/sql/data_source.sql index cb0c15a..f1375a7 100644 --- a/sql/data_source.sql +++ b/sql/data_source.sql @@ -1,3 +1,32 @@ +-- Data Agent metadata database initialization script. +-- Default metadata database used by application.properties: +-- jdbc:mysql://localhost:3306/data_agent + +CREATE DATABASE IF NOT EXISTS `data_agent` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE `data_agent`; + +SET NAMES utf8mb4; + +-- 用户表(带身份源抽象:兼容本系统账号 / 钉钉 / 飞书 / 企业微信) +CREATE TABLE IF NOT EXISTS `sys_user` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` VARCHAR(64) NOT NULL COMMENT '登录名;外部身份源用户=身份源昵称(可重名,靠 uk_idp 区分)', + `password_hash` VARCHAR(255) NULL COMMENT 'PBKDF2 哈希,仅 LOCAL 身份源使用;外部身份源用户为空', + `display_name` VARCHAR(64) NOT NULL COMMENT '显示名', + `role_id` INT NOT NULL DEFAULT 0 COMMENT '角色ID;0=未分配角色(无任何表权限)', + `idp_type` VARCHAR(16) NOT NULL DEFAULT 'LOCAL' COMMENT '身份源:LOCAL=本系统账号 / DINGTALK / FEISHU / WECOM', + `idp_user_id` VARCHAR(64) NULL COMMENT '身份源里的用户ID(LOCAL为空)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_username` (`username`), + UNIQUE KEY `uk_idp` (`idp_type`, `idp_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户'; + CREATE TABLE IF NOT EXISTS `datasource` ( `id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` VARCHAR(255) DEFAULT NULL COMMENT '数据源名称', @@ -15,7 +44,7 @@ CREATE TABLE IF NOT EXISTS `datasource` ( `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据源表'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='数据源表'; CREATE TABLE IF NOT EXISTS `table_info` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', @@ -106,14 +135,33 @@ CREATE TABLE IF NOT EXISTS `report` ( KEY `idx_session_id` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告表'; -CREATE TABLE IF NOT EXISTS `agentscope_sessions` ( - `session_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, - `state_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, - `item_index` int(11) NOT NULL DEFAULT '0', - `state_data` longtext COLLATE utf8mb4_unicode_ci NOT NULL, - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`session_id`,`state_key`,`item_index`) +CREATE TABLE IF NOT EXISTS `scheduled_agent_task` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT 'Primary key', + `name` VARCHAR(255) NOT NULL COMMENT 'Task name', + `prompt` TEXT NOT NULL COMMENT 'Prompt sent to the agent on each run', + `schedule_type` VARCHAR(32) NOT NULL COMMENT 'DAILY, INTERVAL, or CRON', + `schedule_expr` VARCHAR(128) NOT NULL COMMENT 'HH:mm[:ss], ISO-8601 duration, or cron', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether dispatch can pick up the task', + `lock_until` DATETIME DEFAULT NULL COMMENT 'Run lock expiration', + `lock_owner` VARCHAR(64) DEFAULT NULL COMMENT 'Run lock owner token', + `next_run_at` DATETIME DEFAULT NULL COMMENT 'Next due time', + `last_run_at` DATETIME DEFAULT NULL COMMENT 'Last run finish time', + `last_status` VARCHAR(32) DEFAULT NULL COMMENT 'Last run status', + `last_error` TEXT DEFAULT NULL COMMENT 'Last run error', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update time', + PRIMARY KEY (`id`), + KEY `idx_due_task` (`enabled`, `next_run_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Scheduled agent task'; + +CREATE TABLE IF NOT EXISTS `agentscope_sessions` ( + `session_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `state_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `item_index` int(11) NOT NULL DEFAULT '0', + `state_data` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`session_id`, `state_key`, `item_index`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `session_datasource` ( @@ -125,6 +173,33 @@ CREATE TABLE IF NOT EXISTS `session_datasource` ( KEY `idx_datasource_id` (`datasource_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话与数据源绑定表'; +CREATE TABLE IF NOT EXISTS `mcp_server` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` VARCHAR(255) NOT NULL COMMENT 'MCP Server 名称', + `transport_type` VARCHAR(32) DEFAULT NULL COMMENT '传输类型', + `client_type` VARCHAR(32) DEFAULT NULL COMMENT '客户端类型', + `command` VARCHAR(500) DEFAULT NULL COMMENT 'STDIO 命令', + `args` TEXT DEFAULT NULL COMMENT 'STDIO 参数 JSON', + `env` TEXT DEFAULT NULL COMMENT '环境变量 JSON', + `url` VARCHAR(1000) DEFAULT NULL COMMENT 'HTTP/SSE 地址', + `headers` TEXT DEFAULT NULL COMMENT '请求头 JSON', + `query_params` TEXT DEFAULT NULL COMMENT '查询参数 JSON', + `timeout` BIGINT DEFAULT NULL COMMENT '请求超时时间', + `initialization_timeout` BIGINT DEFAULT NULL COMMENT '初始化超时时间', + `enable_elicitation` TINYINT(1) DEFAULT NULL COMMENT '是否启用 elicitation', + `http_version` VARCHAR(32) DEFAULT NULL COMMENT 'HTTP 版本', + `connect_timeout` BIGINT DEFAULT NULL COMMENT '连接超时时间', + `redirect_policy` VARCHAR(32) DEFAULT NULL COMMENT '重定向策略', + `status` VARCHAR(20) DEFAULT NULL COMMENT '状态', + `description` TEXT DEFAULT NULL COMMENT '描述', + `creator_id` BIGINT DEFAULT NULL COMMENT '创建者ID', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_mcp_server_name` (`name`), + KEY `idx_mcp_server_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MCP Server 配置表'; + CREATE TABLE IF NOT EXISTS `metric_info` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `datasource_id` INT NOT NULL COMMENT '关联数据源ID', @@ -144,7 +219,7 @@ CREATE TABLE IF NOT EXISTS `metric_info` ( KEY `idx_datasource_aliases` (`datasource_id`, `aliases`(255)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='指标口径表'; --- 示例:销售额口径。请按你实际的 datasource_id 调整后再执行(取消注释)。 +-- 示例: 销售额口径。请按你实际的 datasource_id 调整后再执行(取消注释)。 -- INSERT INTO `metric_info` -- (`datasource_id`, `metric_key`, `name`, `aliases`, -- `measure_expr`, `filters`, `time_field`, `description`) @@ -190,25 +265,6 @@ CREATE TABLE IF NOT EXISTS `role_hidden_column` ( KEY `idx_role_id` (`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色-隐藏列(黑名单)'; --- 用户表(带身份源抽象:兼容本系统账号 / 钉钉 / 飞书 / 企业微信) -CREATE TABLE IF NOT EXISTS `sys_user` ( - `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `username` VARCHAR(64) NOT NULL COMMENT '登录名;外部身份源用户=身份源昵称(可重名,靠 uk_idp 区分)', - `password_hash` VARCHAR(255) NULL COMMENT 'PBKDF2 哈希,仅 LOCAL 身份源使用;外部身份源用户为空', - `display_name` VARCHAR(64) NOT NULL COMMENT '显示名', - `role_id` INT NOT NULL DEFAULT 0 COMMENT '角色ID;0=未分配角色(无任何表权限)', - `idp_type` VARCHAR(16) NOT NULL DEFAULT 'LOCAL' COMMENT '身份源:LOCAL=本系统账号 / DINGTALK / FEISHU / WECOM', - `idp_user_id` VARCHAR(64) NULL COMMENT '身份源里的用户ID(LOCAL为空)', - `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', - `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`), - KEY `idx_username` (`username`), - UNIQUE KEY `uk_idp` (`idp_type`, `idp_user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户'; --- 说明:username 用普通索引而非唯一键——外部身份源昵称允许重名(身份靠 uk_idp 区分); --- LOCAL 本地账号的用户名唯一性由应用层(AuthService 创建用户时)保证。 - CREATE TABLE IF NOT EXISTS `user_session` ( `user_id` INT NOT NULL COMMENT '用户ID,关联 sys_user.id', `session_id` VARCHAR(255) NOT NULL COMMENT '会话ID(对应 agentscope_sessions.session_id)', @@ -216,30 +272,3 @@ CREATE TABLE IF NOT EXISTS `user_session` ( PRIMARY KEY (`user_id`, `session_id`), KEY `idx_session_id` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户-会话归属表'; - -CREATE TABLE IF NOT EXISTS `mcp_server` ( - `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `name` VARCHAR(255) NOT NULL COMMENT 'MCP Server 名称', - `transport_type` VARCHAR(32) DEFAULT NULL COMMENT '传输类型', - `client_type` VARCHAR(32) DEFAULT NULL COMMENT '客户端类型', - `command` VARCHAR(500) DEFAULT NULL COMMENT '启动命令', - `args` TEXT DEFAULT NULL COMMENT '启动参数', - `env` TEXT DEFAULT NULL COMMENT '环境变量', - `url` VARCHAR(1000) DEFAULT NULL COMMENT '服务 URL', - `headers` TEXT DEFAULT NULL COMMENT '请求头', - `query_params` TEXT DEFAULT NULL COMMENT '查询参数', - `timeout` BIGINT DEFAULT NULL COMMENT '超时时间', - `initialization_timeout` BIGINT DEFAULT NULL COMMENT '初始化超时时间', - `enable_elicitation` TINYINT(1) DEFAULT NULL COMMENT '是否启用 elicitation', - `http_version` VARCHAR(32) DEFAULT NULL COMMENT 'HTTP 版本', - `connect_timeout` BIGINT DEFAULT NULL COMMENT '连接超时时间', - `redirect_policy` VARCHAR(32) DEFAULT NULL COMMENT '重定向策略', - `status` VARCHAR(32) DEFAULT NULL COMMENT '状态', - `description` TEXT DEFAULT NULL COMMENT '描述', - `creator_id` BIGINT DEFAULT NULL COMMENT '创建者ID', - `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', - `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_mcp_server_name` (`name`), - KEY `idx_mcp_server_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MCP Server 配置表';