Compare commits

...
14 Commits
Author SHA1 Message Date
ji ruo ee413324c8 docs(release): prepare v0.3.17 2026-10-10 09:28:39 +08:00
RuoJi6 5f9d491681 Merge pull request #4 from RuoJi6/codex/schedule-date-range
支持计划任务日期范围与日历选择
2026-10-10 09:17:42 +08:00
ji ruo 0af7132ab7 修复计划暂停来源和时间线刷新 2026-10-10 09:16:28 +08:00
ji ruo 87e62fb904 支持计划任务批量选择操作 2026-10-10 09:01:21 +08:00
ji ruo 96a10878ad 重新设计计划任务运行时间线 2026-10-10 08:56:13 +08:00
ji ruo 8a1c9debbb 让立即运行直接恢复任务 2026-10-10 08:50:15 +08:00
ji ruo 8067f3286b 修复计划任务立即运行并区分状态颜色 2026-10-10 08:45:06 +08:00
ji ruo a5cfec025f 固定显示计划任务时间线入口 2026-10-10 08:39:55 +08:00
ji ruo e52fe67171 将计划任务改为字段表格展示 2026-10-10 00:47:14 +08:00
ji ruo aed3062b4d 增加计划任务检索分页和时间线 2026-10-10 00:38:22 +08:00
ji ruo 30db68a86a 优化计划任务列表展示 2026-10-10 00:30:51 +08:00
ji ruo 98321d8262 支持计划任务日期范围 2026-10-09 23:50:05 +08:00
RuoJi6 6a2bba4637 Merge pull request #3 from RuoJi6/codex/scheduled-tasks
新增日历式计划任务
2026-10-09 23:21:06 +08:00
ji ruo 3254c90a03 新增日历式计划任务 2026-10-09 23:05:44 +08:00
22 changed files with 2515 additions and 40 deletions

No files matched your search

+9
View File
@@ -4,6 +4,15 @@
## [Unreleased]
## [0.3.17] - 2026-10-10
### 计划任务
- 新增日历式计划任务,支持按时间规则创建、编辑、启停和删除任务。
- 任务列表支持查看计划状态与下一次运行时间,并补充计划任务管理页面。
- 支持按日期范围筛选计划任务、日历式运行时间线和批量选择操作。
- 优化立即运行、暂停来源与状态颜色展示,并在计划状态变化后刷新时间线。
## [0.3.16] - 2026-10-09
### 访问控制
+7 -7
View File
@@ -95,7 +95,7 @@ docker compose up -d # 拉取 ruoji6/artex 镜像 + postgres
镜像已含常用工具(ripgrep/curl/vim/npm/nmap…);`./skills` 与 `./data` 以绑定挂载持久化。
`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.16`(完整镜像地址 `ruoji6/artex:v0.3.16`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。
`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.17`(完整镜像地址 `ruoji6/artex:v0.3.17`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。
远程 MCP 可在系统设置中选择 `http`(Streamable HTTP)或 `sse`(旧版 SSE)。
旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的
@@ -165,12 +165,12 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
### 维护者:通过 GitHub Actions 发布在线更新
发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.16`;后续版本必须高于用户正在运行的版本。
发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.17`;后续版本必须高于用户正在运行的版本。
```bash
git push origin main
git tag v0.3.16
git push origin v0.3.16
git tag v0.3.17
git push origin v0.3.17
```
工作流固定标签对应的提交,用 Node.js 22 导出前端,按照 `go.mod` 指定的 Go 版本编译内嵌前端的五个平台程序,并生成 ZIP 与 `SHA256SUMS`。AMD64 和 ARM64 镜像分别在 GitHub 原生 runner 上构建,连接临时 PostgreSQL 验证程序启动、版本和内嵌前端;两种架构都通过后发布多架构镜像的版本标签与 `latest`。镜像发布成功后上传完整 ZIP 和校验文件,最后公开 Release。已公开的同版本不允许覆盖,应发布新标签。
@@ -204,10 +204,10 @@ docker compose logs --tail=50 artex
```bash
# 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。
sha256sum --ignore-missing --check SHA256SUMS
unzip artex-0.3.16-linux-arm64.zip
unzip artex-0.3.17-linux-arm64.zip
sudo docker stop artex-artex-1
sudo docker cp artex-artex-1:/app/artex ./artex-before-migration
sudo docker cp ./artex-0.3.16-linux-arm64/artex artex-artex-1:/app/artex
sudo docker cp ./artex-0.3.17-linux-arm64/artex artex-artex-1:/app/artex
sudo docker start artex-artex-1
curl --fail http://127.0.0.1:8787/api/health
```
@@ -231,7 +231,7 @@ cd ARTEX
```bash
cd ARTEX
git pull # 更新 compose / 脚本(可选)
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.16;不设则用 latest
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.17;不设则用 latest
docker compose pull artex
docker compose up -d artex # 换新镜像重启 → 自动迁移 schema
docker image prune -f # 清理旧镜像(可选)
+2 -1
View File
@@ -27,7 +27,8 @@ func Causef(code, short, format string, args ...any) *AbortCause {
var (
// Task-level execution context.
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
AbortPausedBySchedule = cause("paused_by_schedule", "计划窗口结束,暂停任务", "任务将在下一个计划窗口恢复,本次执行已停止")
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户通过任务控制接口(POST /api/tasks/{id}/control,action=pause)暂停了任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复任务后重新领取并从头执行")
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 Agent 暂停了任务",
"编排 Agent 调用了 pause_task 工具暂停本任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复后重新执行")
+405
View File
@@ -0,0 +1,405 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// TaskSchedule is a persisted calendar window controlling one or more tasks.
// Weekdays is kept as JSON text so the API can expose a stable []int shape.
type TaskSchedule struct {
ID int64 `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
TimezoneMode string `json:"timezone_mode"`
ScheduleType string `json:"schedule_type"`
RunDate string `json:"run_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
Weekdays []int `json:"weekdays,omitempty"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
StartImmediately bool `json:"start_immediately"`
Status string `json:"status"`
LastWindowKey string `json:"last_window_key,omitempty"`
LastTransitionAt *time.Time `json:"last_transition_at,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TaskIDs []int64 `json:"task_ids"`
}
type TaskScheduleInput struct {
Name string
Enabled bool
TimezoneMode string
ScheduleType string
RunDate string
EndDate string
Weekdays []int
StartTime string
EndTime string
StartImmediately bool
TaskIDs []int64
}
const scheduleCols = `id,name,enabled,timezone_mode,schedule_type,
COALESCE(to_char(run_date,'YYYY-MM-DD'),''),weekdays,to_char(start_time,'HH24:MI:SS'),
COALESCE(to_char(end_date,'YYYY-MM-DD'),''),
COALESCE(to_char(end_time,'HH24:MI:SS'),''),start_immediately,status,last_window_key,
last_transition_at,last_error,created_at,updated_at`
func marshalWeekdays(days []int) string {
if len(days) == 0 {
return "[]"
}
b, _ := json.Marshal(days)
return string(b)
}
func scanSchedule(sc interface{ Scan(...any) error }) (*TaskSchedule, error) {
var s TaskSchedule
var rawDays string
if err := sc.Scan(&s.ID, &s.Name, &s.Enabled, &s.TimezoneMode, &s.ScheduleType,
&s.RunDate, &rawDays, &s.StartTime, &s.EndDate, &s.EndTime, &s.StartImmediately,
&s.Status, &s.LastWindowKey, &s.LastTransitionAt, &s.LastError,
&s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(rawDays), &s.Weekdays)
if s.Weekdays == nil {
s.Weekdays = []int{}
}
return &s, nil
}
func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return in, fmt.Errorf("计划名称不能为空")
}
if in.TimezoneMode == "" {
in.TimezoneMode = "beijing"
}
if in.TimezoneMode != "beijing" && in.TimezoneMode != "system" {
return in, fmt.Errorf("时区必须选择北京时间或系统当前时区")
}
if in.ScheduleType != "once" && in.ScheduleType != "weekly" {
return in, fmt.Errorf("规则必须选择一次性或按周")
}
if _, err := time.Parse("15:04", in.StartTime); err != nil {
return in, fmt.Errorf("开始时间格式不正确,请使用 HH:MM")
}
if in.EndTime != "" && in.EndTime != "24:00" {
if _, err := time.Parse("15:04", in.EndTime); err != nil {
return in, fmt.Errorf("结束时间格式不正确,请使用 HH:MM")
}
}
if in.ScheduleType == "once" {
if _, err := time.Parse("2006-01-02", in.RunDate); err != nil {
return in, fmt.Errorf("开始日期格式不正确")
}
if in.EndDate != "" {
startDate, _ := time.Parse("2006-01-02", in.RunDate)
endDate, err := time.Parse("2006-01-02", in.EndDate)
if err != nil {
return in, fmt.Errorf("截止日期格式不正确")
}
if endDate.Before(startDate) {
return in, fmt.Errorf("截止日期不能早于开始日期")
}
if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" {
if in.EndTime <= in.StartTime {
return in, fmt.Errorf("同一天的结束时间必须晚于开始时间")
}
}
}
in.Weekdays = nil
} else {
seen := map[int]bool{}
for _, day := range in.Weekdays {
if day < 1 || day > 7 || seen[day] {
return in, fmt.Errorf("星期选择必须为周一至周日且不能重复")
}
seen[day] = true
}
if len(in.Weekdays) == 0 {
return in, fmt.Errorf("按周计划至少选择一天")
}
sort.Ints(in.Weekdays)
in.RunDate = ""
in.EndDate = ""
}
seenTasks := map[int64]bool{}
for _, id := range in.TaskIDs {
if id <= 0 || seenTasks[id] {
return in, fmt.Errorf("绑定任务编号无效或重复")
}
seenTasks[id] = true
}
if len(in.TaskIDs) > 100 {
return in, fmt.Errorf("最多选择100个任务")
}
return in, nil
}
func (d *DB) CreateTaskSchedule(in TaskScheduleInput) (*TaskSchedule, error) {
var err error
if in, err = normalizeScheduleInput(in); err != nil {
return nil, err
}
tx, err := d.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
return createTaskScheduleTxCommit(tx, in)
}
func createTaskScheduleTxCommit(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule, error) {
s, err := insertTaskSchedule(tx, in)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return s, nil
}
func insertTaskSchedule(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule, error) {
row := tx.QueryRow(`INSERT INTO task_schedules(name,enabled,timezone_mode,schedule_type,run_date,end_date,weekdays,start_time,end_time,start_immediately)
VALUES ($1,$2,$3,$4,NULLIF($5,'')::date,NULLIF($6,'')::date,$7,$8::time,NULLIF($9,'')::time,$10) RETURNING `+scheduleCols,
in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
s, err := scanSchedule(row)
if err != nil {
return nil, err
}
for _, taskID := range in.TaskIDs {
if taskID <= 0 {
return nil, fmt.Errorf("task_id 无效")
}
var exists bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&exists); err != nil || !exists {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("任务 #%d 不存在", taskID)
}
if _, err := tx.Exec(`INSERT INTO task_schedule_tasks(schedule_id,task_id) VALUES($1,$2)`, s.ID, taskID); err != nil {
return nil, err
}
}
s.TaskIDs = append([]int64(nil), in.TaskIDs...)
return s, nil
}
func (d *DB) UpdateTaskSchedule(id int64, in TaskScheduleInput) (*TaskSchedule, error) {
var err error
if in, err = normalizeScheduleInput(in); err != nil {
return nil, err
}
tx, err := d.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
row := tx.QueryRow(`UPDATE task_schedules SET name=$2,enabled=$3,timezone_mode=$4,schedule_type=$5,run_date=NULLIF($6,'')::date,end_date=NULLIF($7,'')::date,weekdays=$8,start_time=$9::time,end_time=NULLIF($10,'')::time,start_immediately=$11,status=CASE WHEN $3 THEN CASE WHEN status='completed' THEN 'waiting' ELSE status END ELSE 'paused' END,last_error='',last_window_key='' WHERE id=$1 RETURNING `+scheduleCols,
id, in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
s, err := scanSchedule(row)
if err != nil {
return nil, err
}
if _, err := tx.Exec(`DELETE FROM task_schedule_tasks WHERE schedule_id=$1`, id); err != nil {
return nil, err
}
for _, taskID := range in.TaskIDs {
if _, err := tx.Exec(`INSERT INTO task_schedule_tasks(schedule_id,task_id) VALUES($1,$2)`, id, taskID); err != nil {
return nil, err
}
}
s.TaskIDs = append([]int64(nil), in.TaskIDs...)
if err := tx.Commit(); err != nil {
return nil, err
}
return s, nil
}
func (d *DB) DeleteTaskSchedule(id int64) error {
_, err := d.Exec(`DELETE FROM task_schedules WHERE id=$1`, id)
return err
}
func (d *DB) SetTaskScheduleEnabled(id int64, enabled bool) error {
_, err := d.Exec(`UPDATE task_schedules SET enabled=$2,status=CASE WHEN $2 THEN 'waiting' ELSE 'paused' END,last_error='' WHERE id=$1`, id, enabled)
return err
}
func (d *DB) GetTaskSchedule(id int64) (*TaskSchedule, error) {
s, err := scanSchedule(d.QueryRow(`SELECT `+scheduleCols+` FROM task_schedules WHERE id=$1`, id))
if err != nil {
return nil, err
}
s.TaskIDs, err = d.scheduleTaskIDs(id)
return s, err
}
func (d *DB) ListTaskSchedules() ([]*TaskSchedule, error) {
rows, err := d.Query(`SELECT ` + scheduleCols + ` FROM task_schedules ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*TaskSchedule{}
for rows.Next() {
s, err := scanSchedule(rows)
if err != nil {
return nil, err
}
s.TaskIDs, err = d.scheduleTaskIDs(s.ID)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (d *DB) ListEnabledTaskSchedules() ([]*TaskSchedule, error) {
rows, err := d.Query(`SELECT ` + scheduleCols + ` FROM task_schedules WHERE enabled ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*TaskSchedule{}
for rows.Next() {
s, err := scanSchedule(rows)
if err != nil {
return nil, err
}
s.TaskIDs, err = d.scheduleTaskIDs(s.ID)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (d *DB) scheduleTaskIDs(id int64) ([]int64, error) {
rows, err := d.Query(`SELECT task_id FROM task_schedule_tasks WHERE schedule_id=$1 ORDER BY task_id`, id)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []int64{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (d *DB) TouchTaskSchedule(id int64, status, windowKey, lastError string) error {
_, err := d.Exec(`UPDATE task_schedules SET status=$2,last_window_key=$3,last_transition_at=now(),last_error=$4 WHERE id=$1`, id, status, windowKey, lastError)
return err
}
func (d *DB) MarkTaskScheduleManual(id int64) error {
_, err := d.Exec(`UPDATE task_schedules SET status='running',last_window_key='manual',last_transition_at=now(),last_error='' WHERE id=$1`, id)
return err
}
// ScheduleRunStats summarizes completed schedule window entries for list views.
// The scheduler records one "running" history entry whenever a new window opens.
type ScheduleRunStats struct {
RunCount int `json:"run_count"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
}
func (d *DB) ScheduleRunStats(id int64) (ScheduleRunStats, error) {
var stats ScheduleRunStats
var lastRunAt sql.NullTime
err := d.QueryRow(`
SELECT COUNT(*) FILTER (WHERE task_id IS NULL AND action='running'),
MAX(created_at) FILTER (WHERE task_id IS NULL AND action='running')
FROM task_schedule_history WHERE schedule_id=$1`, id).Scan(&stats.RunCount, &lastRunAt)
if lastRunAt.Valid {
stats.LastRunAt = &lastRunAt.Time
}
return stats, err
}
// TaskScheduleTasks returns all schedule IDs that reference a task.
func (d *DB) TaskScheduleTasks(taskID int64) ([]int64, error) {
rows, err := d.Query(`SELECT schedule_id FROM task_schedule_tasks WHERE task_id=$1 ORDER BY schedule_id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := []int64{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (d *DB) SetSchedulePaused(scheduleID, taskID int64, paused bool) error {
_, err := d.Exec(`UPDATE task_schedule_tasks SET paused_by_schedule=$3 WHERE schedule_id=$1 AND task_id=$2`, scheduleID, taskID, paused)
return err
}
// TaskPausedBySchedule reads the authoritative task-level pause origin. The
// scheduler writes this flag when it pauses a task, so callers must use it to
// distinguish a scheduled pause from a manual pause.
func (d *DB) TaskPausedBySchedule(taskID int64) (bool, error) {
var paused bool
err := d.QueryRow(`SELECT schedule_paused FROM tasks WHERE id=$1 AND deleted_at IS NULL`, taskID).Scan(&paused)
return paused, err
}
// ValidateTaskScheduleInput shares validation between task creation and schedules.
func ValidateTaskScheduleInput(in TaskScheduleInput) error {
_, err := normalizeScheduleInput(in)
return err
}
type ScheduleHistory struct {
ID int64 `json:"id"`
TaskID *int64 `json:"task_id,omitempty"`
Action string `json:"action"`
Success bool `json:"success"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
}
func (d *DB) RecordScheduleHistory(id, taskID int64, action string, success bool, message string) error {
_, err := d.Exec(`INSERT INTO task_schedule_history(schedule_id,task_id,action,success,message) VALUES($1,NULLIF($2,0),$3,$4,$5)`, id, taskID, action, success, message)
return err
}
func (d *DB) ListScheduleHistory(id int64) ([]ScheduleHistory, error) {
rows, err := d.Query(`SELECT id,task_id,action,success,message,created_at FROM task_schedule_history WHERE schedule_id=$1 ORDER BY id DESC LIMIT 100`, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ScheduleHistory{}
for rows.Next() {
var item ScheduleHistory
if err := rows.Scan(&item.ID, &item.TaskID, &item.Action, &item.Success, &item.Message, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
+56
View File
@@ -1027,6 +1027,51 @@ CREATE TABLE IF NOT EXISTS scheduler_state (
value TEXT NOT NULL DEFAULT ''
);
-- =====================================================================
-- J1. Calendar based task schedules
-- =====================================================================
CREATE TABLE IF NOT EXISTS task_schedules (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
timezone_mode TEXT NOT NULL DEFAULT 'beijing'
CHECK (timezone_mode IN ('beijing','system')),
schedule_type TEXT NOT NULL DEFAULT 'weekly'
CHECK (schedule_type IN ('once','weekly')),
run_date DATE,
end_date DATE,
weekdays TEXT NOT NULL DEFAULT '',
start_time TIME NOT NULL,
end_time TIME,
start_immediately BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'waiting'
CHECK (status IN ('waiting','running','outside','completed','error','paused')),
last_window_key TEXT NOT NULL DEFAULT '',
last_transition_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((schedule_type = 'once' AND run_date IS NOT NULL) OR
(schedule_type = 'weekly' AND run_date IS NULL)),
CHECK ((schedule_type = 'once') OR weekdays <> '')
);
CREATE INDEX IF NOT EXISTS idx_task_schedules_enabled ON task_schedules(enabled, id);
ALTER TABLE task_schedules ADD COLUMN IF NOT EXISTS end_date DATE;
DROP TRIGGER IF EXISTS trg_task_schedules_upd ON task_schedules;
CREATE TRIGGER trg_task_schedules_upd BEFORE UPDATE ON task_schedules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS task_schedule_tasks (
schedule_id BIGINT NOT NULL REFERENCES task_schedules(id) ON DELETE CASCADE,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
paused_by_schedule BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (schedule_id, task_id)
);
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_paused BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_task_schedule_tasks_task ON task_schedule_tasks(task_id);
ALTER TABLE task_schedule_tasks ADD COLUMN IF NOT EXISTS paused_by_schedule BOOLEAN NOT NULL DEFAULT false;
-- =====================================================================
-- K. 拦截规则
-- =====================================================================
@@ -1475,3 +1520,14 @@ ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS reviewed_ids JSONB NOT NULL DEFAULT '[]';
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS conclusion JSONB NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_finding_case_reviews_active_task ON finding_case_review_runs(task_id) WHERE state IN ('queued','running');
CREATE TABLE IF NOT EXISTS task_schedule_history (
id BIGSERIAL PRIMARY KEY,
schedule_id BIGINT NOT NULL REFERENCES task_schedules(id) ON DELETE CASCADE,
task_id BIGINT,
action TEXT NOT NULL,
success BOOLEAN NOT NULL,
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_schedule_history ON task_schedule_history(schedule_id,id DESC);
+18
View File
@@ -144,6 +144,7 @@ func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSe
// TaskCreateOptions contains the task data that must be committed atomically
// with the task/exploration row.
type TaskCreateOptions struct {
Schedule *TaskScheduleInput
Name string // 可选任务名称;空=未命名
CategoryID *int64
SourceTaskIDs []int64
@@ -248,6 +249,23 @@ RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, descripti
} else {
t.LLMFailoverState = "ready"
}
if opts.Schedule != nil {
in, err := normalizeScheduleInput(*opts.Schedule)
if err != nil {
return nil, err
}
in.TaskIDs = []int64{t.ID}
if _, err = insertTaskSchedule(tx, in); err != nil {
return nil, err
}
if !in.StartImmediately {
if _, err = tx.Exec(`UPDATE tasks SET paused=true,schedule_paused=true,queue_mode='bootstrap' WHERE id=$1`, t.ID); err != nil {
return nil, err
}
t.Paused = true
t.QueueMode = "bootstrap"
}
}
return t, tx.Commit()
}
+234
View File
@@ -0,0 +1,234 @@
package server
import (
"fmt"
"strconv"
"strings"
"time"
_ "time/tzdata"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
type scheduleWindow struct {
active bool
complete bool
key string
nextStart *time.Time
nextEnd *time.Time
}
func scheduleLocation(mode string) *time.Location {
if mode == "system" {
return time.Local
}
loc, _ := time.LoadLocation("Asia/Shanghai")
return loc
}
func scheduleClock(day time.Time, clock string, loc *time.Location) time.Time {
parts := strings.Split(clock, ":")
h, _ := strconv.Atoi(parts[0])
m, _ := strconv.Atoi(parts[1])
return time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, loc)
}
// end before start means next-day end. Equal midnight means a full day;
// 24:00 is accepted for the end and normalised by PostgreSQL.
func windowBounds(day time.Time, s *db.TaskSchedule, loc *time.Location) (time.Time, *time.Time) {
start := scheduleClock(day, s.StartTime, loc)
if s.EndTime == "" {
return start, nil
}
end := scheduleClock(day, s.EndTime, loc)
if !end.After(start) {
end = scheduleClock(day.AddDate(0, 0, 1), s.EndTime, loc)
}
return start, &end
}
func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow {
loc := scheduleLocation(s.TimezoneMode)
now = now.In(loc)
if s.ScheduleType == "once" {
day, err := time.ParseInLocation("2006-01-02", s.RunDate, loc)
if err != nil {
return scheduleWindow{}
}
var start time.Time
var end *time.Time
if s.EndDate != "" {
endDay, parseErr := time.ParseInLocation("2006-01-02", s.EndDate, loc)
if parseErr != nil {
return scheduleWindow{}
}
start = scheduleClock(day, s.StartTime, loc)
endClock := s.EndTime
if endClock == "" {
endClock = "24:00"
}
endValue := scheduleClock(endDay, endClock, loc)
end = &endValue
} else {
start, end = windowBounds(day, s, loc)
}
active := !now.Before(start) && (end == nil || now.Before(*end))
complete := end != nil && !now.Before(*end)
// An open-ended one-shot remains active after its start until manually
// paused; there is no end boundary at which it could be considered missed.
key := "once:" + s.RunDate
if s.EndDate != "" {
key += ":" + s.EndDate
}
w := scheduleWindow{active: active, complete: complete, key: key, nextEnd: end}
if now.Before(start) {
w.key = ""
w.nextStart = &start
}
return w
}
// Once an unbounded weekly plan has started, it remains active.
if s.EndTime == "" && strings.HasPrefix(s.LastWindowKey, "active:") {
return scheduleWindow{active: true, key: s.LastWindowKey}
}
days := map[int]bool{}
for _, d := range s.Weekdays {
days[d] = true
}
w := scheduleWindow{}
for offset := -1; offset <= 7; offset++ {
day := now.AddDate(0, 0, offset)
weekday := int(day.Weekday())
if weekday == 0 {
weekday = 7
}
if !days[weekday] {
continue
}
start, end := windowBounds(day, s, loc)
if !now.Before(start) && (end == nil || now.Before(*end)) {
w.active = true
w.key = "active:" + start.Format("2006-01-02T15:04")
w.nextEnd = end
// Do not pause between adjacent windows (e.g. Saturday + Sunday full days).
continue
}
if start.After(now) && (w.nextStart == nil || start.Before(*w.nextStart)) {
w.nextStart = &start
if !w.active {
w.nextEnd = end
}
}
}
return w
}
func (sc *Scheduler) fireTaskSchedules() {
sc.s.scheduleMu.Lock()
defer sc.s.scheduleMu.Unlock()
items, err := sc.pg.ListTaskSchedules()
if err != nil {
return
}
now := time.Now()
open := map[int64]bool{}
windows := map[int64]scheduleWindow{}
for _, item := range items {
w := evaluateScheduleWindow(item, now)
// A service restart must not replay a window that started while the
// service was down. Newly-created schedules are allowed to enter an
// already-open window because their creation is an explicit user action.
if w.active && item.LastWindowKey == "" && item.CreatedAt.Before(sc.startedAt) {
w.active = false
w.key = "missed:" + w.key
}
if strings.HasPrefix(item.LastWindowKey, "missed:") && item.LastWindowKey == "missed:"+w.key {
w.active = false
w.key = item.LastWindowKey
}
if item.LastWindowKey == "manual" && !w.active {
w.active = true
w.key = "manual"
w.complete = false
}
if !item.Enabled || item.Status == "completed" {
w.active = false
}
windows[item.ID] = w
if w.active {
for _, id := range item.TaskIDs {
open[id] = true
}
}
}
// Evaluate the union first, then control each task at most once per tick.
seen := map[int64]bool{}
for _, item := range items {
w := windows[item.ID]
errors := []string{}
for _, id := range item.TaskIDs {
if seen[id] {
continue
}
seen[id] = true
t, ok := sc.s.m.Task(strconv.FormatInt(id, 10))
if !ok {
continue
}
state := t.lifecycleSnapshot()
if db.IsTerminal(state.Status) {
continue
}
action := ""
if open[id] && state.Paused {
action = "resume"
}
if !open[id] && !state.Paused {
action = "pause"
}
if action == "" {
continue
}
// Explicit user pauses are never automatically lifted.
if action == "resume" {
var managed bool
if err := sc.pg.QueryRow(`SELECT schedule_paused FROM tasks WHERE id=$1`, id).Scan(&managed); err != nil || !managed {
continue
}
}
_, err := sc.s.applyTaskControlWithCause(t, action, agent.AbortPausedBySchedule)
message := "计划窗口"
if err != nil {
message = err.Error()
errors = append(errors, fmt.Sprintf("任务 #%d: %s", id, message))
}
_ = sc.pg.RecordScheduleHistory(item.ID, id, action, err == nil, message)
}
status := "outside"
if !item.Enabled {
status = "paused"
} else if w.active {
status = "running"
} else if w.complete {
status = "completed"
} else if item.LastWindowKey == "" {
status = "waiting"
}
if len(errors) > 0 {
status = "error"
}
key := item.LastWindowKey
if w.active {
key = w.key
} else if strings.HasPrefix(w.key, "missed:") {
key = w.key
}
if item.Status != status || key != item.LastWindowKey || len(errors) > 0 {
if err := sc.pg.TouchTaskSchedule(item.ID, status, key, strings.Join(errors, "; ")); err != nil {
continue
}
_ = sc.pg.RecordScheduleHistory(item.ID, 0, status, len(errors) == 0, strings.Join(errors, "; "))
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"testing"
"time"
"github.com/Autumn-27/artex/db"
)
func TestEvaluateScheduleWindowWeeklyCrossMidnight(t *testing.T) {
s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "18:00:00", EndTime: "09:00:00"}
// Tuesday 08:00 is still inside Monday's overnight window.
w := evaluateScheduleWindow(s, time.Date(2026, 10, 6, 8, 0, 0, 0, time.FixedZone("CST", 8*3600)))
if !w.active {
t.Fatal("expected overnight window to be active")
}
// Saturday 10:00 is outside the work-week window.
w = evaluateScheduleWindow(s, time.Date(2026, 10, 10, 10, 0, 0, 0, time.FixedZone("CST", 8*3600)))
if w.active {
t.Fatal("expected weekend to be outside")
}
}
func TestEvaluateScheduleWindowOnce(t *testing.T) {
s := &db.TaskSchedule{ScheduleType: "once", TimezoneMode: "beijing", RunDate: "2026-10-09", StartTime: "18:00:00", EndTime: "23:00:00"}
loc := time.FixedZone("CST", 8*3600)
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 17, 59, 0, 0, loc)); w.active {
t.Fatal("one-shot fired early")
}
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 19, 0, 0, 0, loc)); !w.active {
t.Fatal("one-shot should be active")
}
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 0, 0, 0, 0, loc)); !w.complete {
t.Fatal("one-shot should be complete")
}
}
func TestEvaluateScheduleWindowOpenEnded(t *testing.T) {
s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6}, StartTime: "09:00:00"}
loc := time.FixedZone("CST", 8*3600)
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 10, 0, 0, 0, loc)); !w.active {
t.Fatal("open-ended weekly schedule should stay active")
}
}
func TestEvaluateScheduleWindowOnceDateRange(t *testing.T) {
s := &db.TaskSchedule{
ScheduleType: "once",
TimezoneMode: "beijing",
RunDate: "2026-10-09",
EndDate: "2026-10-11",
StartTime: "18:00:00",
EndTime: "09:00:00",
}
loc := time.FixedZone("CST", 8*3600)
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 12, 0, 0, 0, loc)); !w.active {
t.Fatal("date-range one-shot should remain active between its dates")
}
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 11, 9, 0, 0, 0, loc)); !w.complete || w.active {
t.Fatal("date-range one-shot should complete at its end date and time")
}
}
+1 -1
View File
@@ -1217,7 +1217,7 @@ func taskExecutionPaused(cause error) bool {
return false
}
switch abort.Code {
case "paused_by_user", "paused_by_orchestrator", "paused_on_reload", "paused_race_guard",
case "paused_by_schedule", "paused_by_user", "paused_by_orchestrator", "paused_on_reload", "paused_race_guard",
"queued_for_admission", "llm_unavailable_queued", "task_deleted":
return true
default:
+13 -1
View File
@@ -107,7 +107,10 @@ func (s *Server) admitPausedTask(t *Task) (queued bool, err error) {
return s.admitTaskWhen(t, "resume", true)
}
func (s *Server) admitTaskWhen(t *Task, mode string, requirePaused bool) (queued bool, err error) {
func (s *Server) admitTaskWhen(t *Task, mode string, requirePaused bool) (bool, error) {
return s.admitTaskWhenScheduled(t, mode, requirePaused, false)
}
func (s *Server) admitTaskWhenScheduled(t *Task, mode string, requirePaused, scheduled bool) (queued bool, err error) {
if t == nil {
return false, fmt.Errorf("task not found")
}
@@ -128,6 +131,15 @@ func (s *Server) admitTaskWhen(t *Task, mode string, requirePaused bool) (queued
}
defer s.engine.decInflight(t.ID)
lifecycle := t.lifecycleSnapshot()
if scheduled {
var managed bool
if err := s.m.pg.QueryRow(`SELECT schedule_paused FROM tasks WHERE id=$1`, t.ID).Scan(&managed); err != nil {
return false, err
}
if !managed {
return false, fmt.Errorf("人工暂停优先,计划不能恢复")
}
}
if requirePaused {
if isTerminalStatus(lifecycle.Status) {
return false, fmt.Errorf("终态任务不能执行继续")
+6 -4
View File
@@ -1169,7 +1169,7 @@ func (m *Manager) ApplyTaskAdmission(id, expectedStatus, status string, queued b
WHEN $2 IN ('done','failed','timeout') THEN COALESCE(completed_at, now())
ELSE NULL
END,
paused=false,
paused=false, schedule_paused=false,
queued=$3,
queued_at=CASE
WHEN NOT $3 THEN NULL
@@ -1232,7 +1232,9 @@ func (m *Manager) ApplyTaskAdmission(id, expectedStatus, status string, queued b
// the user pause. queue_mode is intentionally retained so resuming a never-run
// bootstrap task still performs goal decomposition, but the next enqueue receives
// a new queued_at timestamp and therefore moves to the FIFO tail.
func (m *Manager) ApplyTaskPause(id string) error {
func (m *Manager) ApplyTaskPause(id string) error { return m.ApplyTaskPauseOrigin(id, false) }
func (m *Manager) ApplyTaskPauseOrigin(id string, scheduled bool) error {
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
n, err := strconv.ParseInt(id, 10, 64)
@@ -1241,10 +1243,10 @@ func (m *Manager) ApplyTaskPause(id string) error {
}
var mode string
err = m.pg.QueryRow(`UPDATE tasks
SET paused=true, queued=false, queued_at=NULL
SET paused=true, queued=false, queued_at=NULL, schedule_paused=$2
WHERE id=$1 AND deleted_at IS NULL AND paused=false
AND status NOT IN ('done','failed','timeout')
RETURNING COALESCE(queue_mode,'')`, n).Scan(&mode)
RETURNING COALESCE(queue_mode,'')`, n, scheduled).Scan(&mode)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("task %s is unavailable for pause", id)
}
+6 -4
View File
@@ -20,9 +20,10 @@ import (
// + finding watermark + fired-goal set) so a restart resumes without double-firing.
// Triggers only attach to CUSTOM agents.
type Scheduler struct {
s *Server
pg *db.DB
tick time.Duration
s *Server
pg *db.DB
tick time.Duration
startedAt time.Time
}
const (
@@ -34,7 +35,7 @@ const (
)
func newScheduler(s *Server) *Scheduler {
return &Scheduler{s: s, pg: s.m.pg, tick: 5 * time.Second}
return &Scheduler{s: s, pg: s.m.pg, tick: 5 * time.Second, startedAt: time.Now()}
}
// Run loops until ctx is done, ticking the scheduler. Started once from server New.
@@ -115,6 +116,7 @@ func (sc *Scheduler) init() {
}
func (sc *Scheduler) step() {
sc.fireTaskSchedules()
triggers, err := sc.pg.ListEnabledTriggers()
if err != nil {
return
+276
View File
@@ -0,0 +1,276 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
type scheduleRequest struct {
Name string `json:"name"`
Enabled *bool `json:"enabled,omitempty"`
TimezoneMode string `json:"timezone_mode"`
ScheduleType string `json:"schedule_type"`
RunDate string `json:"run_date"`
EndDate string `json:"end_date"`
Weekdays []int `json:"weekdays"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
StartImmediately bool `json:"start_immediately"`
TaskIDs []int64 `json:"task_ids"`
}
func (s *Server) scheduleInput(req scheduleRequest, existing *db.TaskSchedule) db.TaskScheduleInput {
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
} else if existing != nil {
enabled = existing.Enabled
}
return db.TaskScheduleInput{Name: req.Name, Enabled: enabled, TimezoneMode: req.TimezoneMode, ScheduleType: req.ScheduleType,
RunDate: req.RunDate, EndDate: req.EndDate, Weekdays: req.Weekdays, StartTime: req.StartTime, EndTime: req.EndTime,
StartImmediately: req.StartImmediately, TaskIDs: req.TaskIDs}
}
func parseScheduleID(r *http.Request) (int64, error) {
return strconv.ParseInt(r.PathValue("id"), 10, 64)
}
func (s *Server) scheduleDTO(item *db.TaskSchedule) map[string]any {
raw, _ := json.Marshal(item)
var dto map[string]any
_ = json.Unmarshal(raw, &dto)
window := evaluateScheduleWindow(item, time.Now())
if stats, err := s.m.pg.ScheduleRunStats(item.ID); err == nil {
dto["run_count"] = stats.RunCount
if stats.LastRunAt != nil {
dto["last_run_at"] = stats.LastRunAt
}
}
dto["timezone"] = scheduleLocation(item.TimezoneMode).String()
dto["server_time"] = time.Now()
if item.Enabled && item.Status != "completed" {
dto["next_start"] = window.nextStart
dto["next_end"] = window.nextEnd
}
return dto
}
func (s *Server) listSchedules(w http.ResponseWriter, r *http.Request) {
items, err := s.m.pg.ListTaskSchedules()
if err != nil {
writeErr(w, 500, err.Error())
return
}
dtos := []map[string]any{}
for _, item := range items {
dtos = append(dtos, s.scheduleDTO(item))
}
writeJSON(w, 200, map[string]any{"schedules": dtos})
}
func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) {
id, err := parseScheduleID(r)
if err != nil {
writeErr(w, 400, "计划编号无效")
return
}
item, err := s.m.pg.GetTaskSchedule(id)
if err != nil {
writeErr(w, 404, "计划不存在")
return
}
history, err := s.m.pg.ListScheduleHistory(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
dto := s.scheduleDTO(item)
dto["history"] = history
writeJSON(w, 200, dto)
}
func decodeSchedule(r *http.Request) (scheduleRequest, error) {
var req scheduleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return req, fmt.Errorf("bad json: %w", err)
}
if req.Enabled == nil {
v := true
req.Enabled = &v
}
return req, nil
}
func (s *Server) createSchedule(w http.ResponseWriter, r *http.Request) {
req, err := decodeSchedule(r)
if err != nil {
writeErr(w, 400, err.Error())
return
}
item, err := s.m.pg.CreateTaskSchedule(s.scheduleInput(req, nil))
if err != nil {
writeErr(w, 400, err.Error())
return
}
if req.StartImmediately && item.Enabled {
s.runScheduleTasks(item)
}
writeJSON(w, 201, item)
}
func (s *Server) updateSchedule(w http.ResponseWriter, r *http.Request) {
id, err := parseScheduleID(r)
if err != nil {
writeErr(w, 400, "计划编号无效")
return
}
old, err := s.m.pg.GetTaskSchedule(id)
if err != nil {
writeErr(w, 404, "计划不存在")
return
}
req, err := decodeSchedule(r)
if err != nil {
writeErr(w, 400, err.Error())
return
}
item, err := s.m.pg.UpdateTaskSchedule(id, s.scheduleInput(req, old))
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, item)
}
func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) {
id, err := parseScheduleID(r)
if err != nil {
writeErr(w, 400, "计划编号无效")
return
}
if err := s.m.pg.DeleteTaskSchedule(id); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) setScheduleEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
id, err := parseScheduleID(r)
if err != nil {
writeErr(w, 400, "计划编号无效")
return
}
if err := s.m.pg.SetTaskScheduleEnabled(id, enabled); err != nil {
writeErr(w, 500, err.Error())
return
}
if enabled {
if item, err := s.m.pg.GetTaskSchedule(id); err == nil {
if item.StartImmediately {
s.runScheduleTasks(item)
}
}
}
writeJSON(w, 200, map[string]any{"ok": true, "enabled": enabled})
}
func (s *Server) pauseSchedule(w http.ResponseWriter, r *http.Request) {
s.setScheduleEnabled(w, r, false)
}
func (s *Server) resumeSchedule(w http.ResponseWriter, r *http.Request) {
s.setScheduleEnabled(w, r, true)
}
func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) {
id, err := parseScheduleID(r)
if err != nil {
writeErr(w, 400, "计划编号无效")
return
}
item, err := s.m.pg.GetTaskSchedule(id)
if err != nil {
writeErr(w, 404, "计划不存在")
return
}
if err := s.m.pg.MarkTaskScheduleManual(id); err != nil {
writeErr(w, 500, "无法启动计划任务")
return
}
_ = s.m.pg.RecordScheduleHistory(id, 0, "running", true, "手动立即运行")
started, errors := s.runScheduleTasksNow(item)
writeJSON(w, 200, map[string]any{"ok": true, "started": started, "errors": errors})
}
func (s *Server) runScheduleTasks(item *db.TaskSchedule) (int, []string) {
return s.startScheduleTasks(item, false)
}
func (s *Server) runScheduleTasksNow(item *db.TaskSchedule) (int, []string) {
return s.startScheduleTasks(item, true)
}
func (s *Server) startScheduleTasks(item *db.TaskSchedule, forceManualPause bool) (int, []string) {
started := 0
errors := []string{}
for _, taskID := range item.TaskIDs {
t, ok := s.m.Task(strconv.FormatInt(taskID, 10))
if !ok {
errors = append(errors, fmt.Sprintf("任务 #%d 未找到", taskID))
continue
}
state := t.lifecycleSnapshot()
if db.IsTerminal(state.Status) {
errors = append(errors, fmt.Sprintf("任务 #%d 已结束,无法立即运行", taskID))
continue
}
if !state.Paused {
started++
continue
}
managed, err := s.m.pg.TaskPausedBySchedule(taskID)
if err != nil {
errors = append(errors, fmt.Sprintf("任务 #%d 状态读取失败", taskID))
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败")
continue
}
if !managed && !forceManualPause {
errors = append(errors, fmt.Sprintf("任务 #%d 由人工暂停,计划不能强制恢复", taskID))
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "人工暂停优先")
continue
}
_, err = s.applyTaskControlWithCause(t, "resume", agent.AbortPausedByOrchestrator)
if err != nil {
errors = append(errors, fmt.Sprintf("任务 #%d:%s", taskID, err.Error()))
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, err.Error())
continue
}
started++
_ = s.m.pg.SetSchedulePaused(item.ID, taskID, false)
message := "立即运行"
if !managed && forceManualPause {
message = "立即运行(显式恢复人工暂停)"
}
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, message)
}
return started, errors
}
// scheduleRequestFromTask is used by the task creation handler without making
// the public task API depend on database internals.
func scheduleRequestFromTask(raw json.RawMessage) (*scheduleRequest, error) {
if len(strings.TrimSpace(string(raw))) == 0 || string(raw) == "null" {
return nil, nil
}
var req scheduleRequest
if err := json.Unmarshal(raw, &req); err != nil {
return nil, err
}
return &req, nil
}
+45 -19
View File
@@ -39,9 +39,10 @@ var BuildVersion = "dev"
// Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui
// frontend.
type Server struct {
m *Manager
engine *Engine
ctx context.Context
scheduleMu sync.Mutex
m *Manager
engine *Engine
ctx context.Context
skillDir string // root directory for skill subdirectories
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
@@ -234,8 +235,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
log.Printf("[retester] seed: %v", err)
}
go s.evidenceStore().RunGC(s.ctx)
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
// Calendar scheduler starts after persisted tasks are restored.
// 漏洞 IM 推送投递引擎。与 Scheduler 并列但独立:推送的实时性要求(3s)
// 与触发器的业务节奏不同,且两者失败互不牵连——推送卡住不该影响 agent 触发。
go newNotifier(s).Run(s.ctx)
@@ -289,6 +290,7 @@ func (s *Server) restoreTaskRuntimes() {
s.engine.startDeadlineCoordinator(s.ctx, t)
}
}
go newScheduler(s).Run(s.ctx)
// Restore every task that had already been admitted before shutdown. Starting
// only the active UI task left other non-queued tasks counted as concurrency
// occupants without live loops, which could permanently block the persistent
@@ -686,6 +688,14 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks", s.listTasks)
mux.HandleFunc("POST /api/tasks", s.createTask)
mux.HandleFunc("GET /api/schedules", s.listSchedules)
mux.HandleFunc("POST /api/schedules", s.createSchedule)
mux.HandleFunc("GET /api/schedules/{id}", s.getSchedule)
mux.HandleFunc("PATCH /api/schedules/{id}", s.updateSchedule)
mux.HandleFunc("DELETE /api/schedules/{id}", s.deleteSchedule)
mux.HandleFunc("POST /api/schedules/{id}/pause", s.pauseSchedule)
mux.HandleFunc("POST /api/schedules/{id}/resume", s.resumeSchedule)
mux.HandleFunc("POST /api/schedules/{id}/run-now", s.runScheduleNow)
mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories)
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
@@ -1469,18 +1479,19 @@ func truncateReply(s string) string {
}
type createTaskReq struct {
Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务
CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
Schedule *scheduleRequest `json:"schedule,omitempty"`
Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务
CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
}
@@ -1534,8 +1545,19 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "任务级拦截规则无效:"+err.Error())
return
}
var schedule *db.TaskScheduleInput
if req.Schedule != nil {
input := s.scheduleInput(*req.Schedule, nil)
input.TaskIDs = nil
if err := db.ValidateTaskScheduleInput(input); err != nil {
writeErr(w, 400, err.Error())
return
}
schedule = &input
}
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
Schedule: schedule,
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled,
@@ -1556,7 +1578,11 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
log.Printf("[task] 新建任务 #%s «%s» 目标: %s", t.ID, req.Description, req.Goal)
// 共享的建后流程(seed + 种子意图 + 后台目标分解 + engine.Run),与 spawn_task 复用同一段。
// launchTask 内部异步,不阻塞 UI —— 目标分解在后台可见地进行。
s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent)
if schedule == nil || schedule.StartImmediately {
s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent)
} else {
s.engine.Pause(t.ID, agent.AbortPausedBySchedule)
}
writeJSON(w, 201, taskDTO(t, s.resolvedTaskStatus(t)))
}
+10 -2
View File
@@ -115,6 +115,14 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
return out, fmt.Errorf("终态任务不能执行暂停")
}
if lifecycle.Paused {
if pauseCause != agent.AbortPausedBySchedule {
if _, err := s.m.pg.Exec(`UPDATE tasks SET schedule_paused=false WHERE id=$1`, t.ID); err != nil {
return out, err
}
out.Paused = true
out.Status = "paused"
return out, nil
}
return out, fmt.Errorf("任务已经暂停")
}
wasQueued := lifecycle.Queued
@@ -123,7 +131,7 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
pauseCause = agent.AbortPausedByUser
}
s.engine.Pause(t.ID, pauseCause)
if err := s.m.ApplyTaskPause(t.ID); err != nil {
if err := s.m.ApplyTaskPauseOrigin(t.ID, pauseCause == agent.AbortPausedBySchedule); err != nil {
if !wasEnginePaused && !wasQueued {
s.engine.Resume(t)
}
@@ -136,7 +144,7 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
out.Paused, out.Status = true, "paused"
go s.reconcileConcurrency()
case "resume":
queued, err := s.admitPausedTask(t)
queued, err := s.admitTaskWhenScheduled(t, "resume", true, pauseCause == agent.AbortPausedBySchedule)
if err != nil {
return out, err
}
@@ -0,0 +1,914 @@
"use client";
import * as React from "react";
import {
CalendarClock,
ChevronDown,
ChevronRight,
Loader2,
Pause,
Play,
Plus,
Search,
Trash2,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { DateSelect } from "@/components/date-select";
import { TimeSelect } from "@/components/time-select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, type ScheduleInput } from "@/lib/api";
import type { Task, TaskSchedule } from "@/lib/types";
const weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const blank: ScheduleInput = {
name: "",
schedule_type: "weekly",
weekdays: [6, 7],
start_time: "18:00",
end_time: "",
task_ids: [],
timezone_mode: "beijing",
start_immediately: false,
};
function statusText(s: TaskSchedule) {
return (
(
{
waiting: "等待窗口",
running: "窗口运行中",
outside: "窗口外",
completed: "已完成",
error: "错误",
paused: "已暂停",
} as Record<string, string>
)[s.status] ?? "未知状态"
);
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN") : "—";
}
function formatDateValue(value?: string) {
if (!value) return "未设置";
const [year, month, day] = value.split("-");
return year && month && day ? `${year}年${Number(month)}月${Number(day)}日` : value;
}
function formatClock(value?: string) {
return value ? value.slice(0, 5) : "未设置";
}
function periodLabel(item: TaskSchedule) {
if (item.schedule_type === "once") {
return item.end_date
? `一次性 · ${formatDateValue(item.run_date)} 至 ${formatDateValue(item.end_date)}`
: `一次性 · ${formatDateValue(item.run_date)}`;
}
const selected = item.weekdays.map((day) => weekdays[day - 1]).filter(Boolean);
return `每周 · ${selected.length ? selected.join("、") : "未选择星期"}`;
}
function windowLabel(item: TaskSchedule) {
const start = formatClock(item.start_time);
const end = formatClock(item.end_time);
if (item.schedule_type === "once") {
const startText = `${formatDateValue(item.run_date)} ${start}`;
if (item.end_date) return `${startText} 至 ${formatDateValue(item.end_date)} ${item.end_time ? end : "24:00"}`;
if (!item.end_time) return `${startText} 起持续运行`;
return `${startText} 至 ${end}${end <= start ? "(次日)" : ""}`;
}
if (!item.end_time) return `${start} 起持续运行`;
return `${start} 至 ${end}${end <= start ? "(次日)" : ""}`;
}
function startLabel(item: TaskSchedule) {
return item.schedule_type === "once"
? `${formatDateValue(item.run_date)} ${formatClock(item.start_time)}`
: formatClock(item.start_time);
}
function endLabel(item: TaskSchedule) {
if (item.schedule_type === "once" && item.end_date) {
return `${formatDateValue(item.end_date)} ${item.end_time ? formatClock(item.end_time) : "24:00"}`;
}
if (!item.end_time) return "持续运行";
const end = formatClock(item.end_time);
const start = formatClock(item.start_time);
return `${end}${end <= start ? "(次日)" : ""}`;
}
function timezoneLabel(item: TaskSchedule) {
return item.timezone_mode === "beijing" ? "中国北京时间" : `系统时区${item.timezone ? `(${item.timezone})` : ""}`;
}
function nextWindowLabel(value?: string, timezone?: string) {
if (!value) return "—";
try {
const timeZone = timezone && timezone !== "Local" ? timezone : undefined;
return new Intl.DateTimeFormat("zh-CN", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
} catch {
return formatTime(value);
}
}
function scheduleErrorText(value?: string) {
if (!value) return "";
return value
.replaceAll("end_date", "截止日期")
.replaceAll("run_date", "开始日期")
.replaceAll("start_time", "开始时间")
.replaceAll("end_time", "结束时间")
.replaceAll("weekdays", "星期")
.replaceAll("timezone_mode", "时区")
.replaceAll("schedule_type", "规则");
}
type ScheduleHistoryEntry = NonNullable<TaskSchedule["history"]>[number];
function historyActionText(action: string) {
return (
(
{
running: "窗口开始",
outside: "窗口结束",
completed: "计划完成",
waiting: "等待窗口",
paused: "计划暂停",
resume: "恢复任务",
pause: "暂停任务",
error: "执行错误",
} as Record<string, string>
)[action] ?? "状态变化"
);
}
function historyEventClass(event: ScheduleHistoryEntry) {
if (!event.success)
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300";
if (event.action === "running" || event.action === "resume") {
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300";
}
if (event.action === "outside" || event.action === "pause" || event.action === "paused") {
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300";
}
return "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300";
}
function historyDotClass(event: ScheduleHistoryEntry) {
if (!event.success) return "bg-red-500";
if (event.action === "running" || event.action === "resume") return "bg-emerald-500";
if (event.action === "outside" || event.action === "pause" || event.action === "paused") return "bg-amber-500";
return "bg-slate-400";
}
function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; loading: boolean }) {
if (loading) {
return (
<div className="flex items-center gap-2 py-4 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
加载运行时间线…
</div>
);
}
// The API returns newest first. Keep that order so the latest transition is
// visible immediately when a row is expanded.
const events = schedule?.history ?? [];
if (!events.length) {
return <div className="py-4 text-muted-foreground text-sm">暂无运行记录,计划进入第一个窗口后会显示时间线。</div>;
}
return (
<div className="grid gap-3 rounded-lg border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-sm">运行时间线</div>
<span className="text-muted-foreground text-xs">最近 {Math.min(events.length, 20)} 条记录</span>
</div>
<div className="grid gap-0">
{events.slice(0, 20).map((event, index) => (
<div key={event.id} className="relative flex gap-3 pb-3 last:pb-0">
{index < Math.min(events.length, 20) - 1 ? (
<span className="absolute top-4 bottom-0 left-[5px] w-px bg-border" aria-hidden="true" />
) : null}
<span
className={`relative mt-1.5 size-3 shrink-0 rounded-full border-2 border-background shadow-sm ${historyDotClass(event)}`}
title={historyActionText(event.action)}
/>
<div className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline" className={historyEventClass(event)}>
{historyActionText(event.action)}
</Badge>
<span className="text-muted-foreground">{formatTime(event.created_at)}</span>
<span className="text-muted-foreground">{event.task_id ? `任务 #${event.task_id}` : "计划任务"}</span>
</div>
{event.message ? (
<div className={`mt-1 text-xs ${event.success ? "text-muted-foreground" : "text-destructive"}`}>
{event.message}
</div>
) : null}
</div>
</div>
))}
</div>
</div>
);
}
function statusClass(status: TaskSchedule["status"]) {
return (
{
waiting: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-300",
running:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300",
outside:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300",
completed: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300",
error: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300",
paused:
"border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300",
} as Record<TaskSchedule["status"], string>
)[status];
}
function ScheduleForm({
value,
tasks,
onSave,
onCancel,
saving,
}: {
value: ScheduleInput;
tasks: Task[];
onSave: (v: ScheduleInput) => void;
onCancel?: () => void;
saving: boolean;
}) {
const [draft, setDraft] = React.useState(value);
const [taskQuery, setTaskQuery] = React.useState("");
const set = (patch: Partial<ScheduleInput>) => setDraft((v) => ({ ...v, ...patch }));
const toggleTask = (id: number, checked: boolean) =>
set({ task_ids: checked ? [...(draft.task_ids ?? []), id] : (draft.task_ids ?? []).filter((x) => x !== id) });
const toggleDay = (day: number) =>
set({
weekdays: (draft.weekdays ?? []).includes(day)
? (draft.weekdays ?? []).filter((x) => x !== day)
: [...(draft.weekdays ?? []), day].sort(),
});
const filteredTasks = React.useMemo(() => {
const query = taskQuery.trim().toLowerCase();
if (!query) return tasks;
return tasks.filter((task) =>
[String(task.id), task.name ?? "", task.description, task.goal].some((value) =>
value.toLowerCase().includes(query),
),
);
}, [taskQuery, tasks]);
let taskListContent: React.ReactNode;
if (tasks.length === 0) {
taskListContent = <span className="text-muted-foreground text-sm">暂无任务</span>;
} else if (filteredTasks.length === 0) {
taskListContent = <span className="text-muted-foreground text-sm">没有匹配的任务</span>;
} else {
taskListContent = filteredTasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 py-1 text-sm">
<Checkbox
id={`schedule-task-${task.id}`}
checked={(draft.task_ids ?? []).includes(Number(task.id))}
onCheckedChange={(v) => toggleTask(Number(task.id), !!v)}
/>
<Label htmlFor={`schedule-task-${task.id}`} className="truncate">
#{task.id} {task.name || task.description}
</Label>
</div>
));
}
return (
<div className="grid gap-4 rounded-lg border p-4">
<div className="grid gap-2">
<Label>计划名称</Label>
<Input value={draft.name} onChange={(e) => set({ name: e.target.value })} placeholder="例如:周末持续扫描" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>规则</Label>
<Select
value={draft.schedule_type}
onValueChange={(value) => set({ schedule_type: value as ScheduleInput["schedule_type"] })}
>
<SelectTrigger className="h-10 w-full bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly">按周</SelectItem>
<SelectItem value="once">指定日期</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>时区</Label>
<Select
value={draft.timezone_mode}
onValueChange={(value) => set({ timezone_mode: value as ScheduleInput["timezone_mode"] })}
>
<SelectTrigger className="h-10 w-full bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beijing">中国北京时间</SelectItem>
<SelectItem value="system">系统当前时区</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{draft.schedule_type === "once" ? (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>开始日期</Label>
<DateSelect
value={draft.run_date ?? ""}
onValueChange={(value) => set({ run_date: value })}
aria-label="开始日期"
/>
</div>
<div className="grid gap-2">
<Label>截止日期(可选)</Label>
<DateSelect
value={draft.end_date ?? ""}
onValueChange={(value) => set({ end_date: value })}
optional
placeholder="不设置截止日期"
aria-label="截止日期,可选"
/>
</div>
</div>
) : (
<div className="grid gap-2">
<Label>星期</Label>
<div className="flex flex-wrap gap-3">
{weekdays.map((label, index) => (
<div key={label} className="flex items-center gap-2 text-sm">
<Checkbox
id={`schedule-weekday-${index + 1}`}
checked={(draft.weekdays ?? []).includes(index + 1)}
onCheckedChange={() => toggleDay(index + 1)}
/>
<Label htmlFor={`schedule-weekday-${index + 1}`}>{label}</Label>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>开始时间</Label>
<TimeSelect
value={draft.start_time}
onValueChange={(value) => set({ start_time: value })}
aria-label="开始时间"
/>
</div>
<div className="grid gap-2">
<Label>结束时间(可选)</Label>
<TimeSelect
value={draft.end_time ?? ""}
onValueChange={(value) => set({ end_time: value })}
optional
aria-label="结束时间,可选"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
一次性计划可设置截止日期;截止日期为空时仅按结束时间控制。结束时间为空表示持续运行,结束时间早于开始时间表示跨午夜窗口。
</p>
<div className="flex items-center gap-2 text-sm">
<Checkbox
id="schedule-start-immediately"
checked={draft.start_immediately}
onCheckedChange={(v) => set({ start_immediately: !!v })}
/>
<Label htmlFor="schedule-start-immediately">创建后立即启动,不等待第一个窗口</Label>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label>绑定任务</Label>
<span className="text-muted-foreground text-xs">已选 {(draft.task_ids ?? []).length} 个</span>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={taskQuery}
onChange={(event) => setTaskQuery(event.target.value)}
placeholder="检索任务名称、描述或 ID"
className="h-9 pl-8"
/>
</div>
<div className="max-h-40 overflow-auto rounded border p-2">{taskListContent}</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button disabled={saving} onClick={() => onSave(draft)}>
{saving && <Loader2 className="animate-spin" data-icon="inline-start" />}保存计划
</Button>
</div>
</div>
);
}
export default function SchedulesPage() {
const [items, setItems] = React.useState<TaskSchedule[]>([]);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [loading, setLoading] = React.useState(true);
const [editing, setEditing] = React.useState<{ id?: number; value: ScheduleInput } | null>(null);
const [saving, setSaving] = React.useState(false);
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState("10");
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [expandedId, setExpandedId] = React.useState<number | null>(null);
const [timelineById, setTimelineById] = React.useState<Record<number, TaskSchedule>>({});
const [timelineLoadingId, setTimelineLoadingId] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [s, t] = await Promise.all([api.schedules(), api.tasks()]);
setItems(s);
setTasks(t.tasks);
} catch (e) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const filteredItems = React.useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return items;
return items.filter((item) =>
[item.name, String(item.id), periodLabel(item), windowLabel(item), ...item.task_ids.map(String)]
.join(" ")
.toLowerCase()
.includes(normalized),
);
}, [items, query]);
const size = Number(pageSize);
const totalPages = Math.max(1, Math.ceil(filteredItems.length / size));
const visibleItems = filteredItems.slice((page - 1) * size, page * size);
const visibleIds = visibleItems.map((item) => item.id);
const selectedVisibleCount = visibleIds.filter((id) => selectedIds.includes(id)).length;
const allVisibleSelected = visibleItems.length > 0 && selectedVisibleCount === visibleItems.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
let headerSelectionState: boolean | "indeterminate" = false;
if (allVisibleSelected) headerSelectionState = true;
else if (someVisibleSelected) headerSelectionState = "indeterminate";
React.useEffect(() => {
setPage((current) => Math.min(current, totalPages));
}, [totalPages]);
const updateQuery = (value: string) => {
setQuery(value);
setPage(1);
};
const updatePageSize = (value: string) => {
setPageSize(value);
setPage(1);
};
const toggleSelected = (id: number, checked: boolean) => {
setSelectedIds((current) => {
if (!checked) return current.filter((selected) => selected !== id);
return current.includes(id) ? current : [...current, id];
});
};
const toggleAllVisible = (checked: boolean) => {
setSelectedIds((current) => {
if (checked) return Array.from(new Set([...current, ...visibleIds]));
return current.filter((id) => !visibleIds.includes(id));
});
};
const refreshTimeline = async (id: number) => {
setTimelineLoadingId(id);
try {
const detail = await api.schedule(id);
setTimelineById((current) => ({ ...current, [id]: detail }));
} catch (e) {
toast.error((e as Error).message);
} finally {
setTimelineLoadingId(null);
}
};
const invalidateTimeline = async (id: number) => {
setTimelineById((current) => {
if (!current[id]) return current;
const next = { ...current };
delete next[id];
return next;
});
if (expandedId === id) await refreshTimeline(id);
};
const toggleTimeline = async (id: number) => {
if (expandedId === id) {
setExpandedId(null);
return;
}
setExpandedId(id);
if (timelineById[id]) return;
await refreshTimeline(id);
};
const save = async (value: ScheduleInput) => {
if (
!value.name.trim() ||
!value.start_time ||
(value.schedule_type === "once" ? !value.run_date : !value.weekdays?.length)
) {
toast.error("请补全计划名称、日期/星期和开始时间");
return;
}
setSaving(true);
try {
if (editing?.id) await api.updateSchedule(editing.id, value);
else await api.createSchedule(value);
toast.success("计划已保存");
setEditing(null);
await load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
};
const remove = async (id: number) => {
if (!window.confirm("确定删除这个计划吗?")) return;
try {
await api.deleteSchedule(id);
setTimelineById((current) => {
const next = { ...current };
delete next[id];
return next;
});
if (expandedId === id) setExpandedId(null);
await load();
} catch (e) {
toast.error((e as Error).message);
}
};
const action = async (id: number, type: "pause" | "resume" | "run") => {
try {
if (type === "pause") await api.pauseSchedule(id);
else if (type === "resume") await api.resumeSchedule(id);
else {
const result = await api.runScheduleNow(id);
if (result.errors?.length) {
toast.warning(
`已启动 ${result.started} 个任务,但有 ${result.errors.length} 个任务未启动:${result.errors.join(";")}`,
);
} else {
toast.success(`已立即运行 ${result.started} 个任务`);
}
}
await invalidateTimeline(id);
await load();
} catch (e) {
toast.error((e as Error).message);
}
};
const bulkRun = async () => {
const ids = [...selectedIds];
if (!ids.length) return;
let started = 0;
const errors: string[] = [];
const results = await Promise.allSettled(ids.map((id) => api.runScheduleNow(id)));
results.forEach((result, index) => {
if (result.status === "rejected") {
errors.push(`计划 #${ids[index]}:${(result.reason as Error).message}`);
return;
}
started += result.value.started;
if (result.value.errors?.length) errors.push(`计划 #${ids[index]}:${result.value.errors.join(";")}`);
});
if (errors.length) {
toast.warning(`已启动 ${started} 个任务,但有 ${errors.length} 项未完成:${errors.join(";")}`);
} else {
toast.success(`已立即运行 ${started} 个任务`);
}
setSelectedIds([]);
await Promise.all(ids.map((id) => invalidateTimeline(id)));
await load();
};
const bulkRemove = async () => {
const ids = [...selectedIds];
if (!ids.length || !window.confirm(`确定删除选中的 ${ids.length} 个计划吗?`)) return;
const results = await Promise.allSettled(ids.map((id) => api.deleteSchedule(id)));
const failed = results.filter((result) => result.status === "rejected").length;
if (failed) toast.warning(`已删除 ${ids.length - failed} 个计划,${failed} 个计划删除失败`);
else toast.success(`已删除 ${ids.length} 个计划`);
setTimelineById((current) => {
const next = { ...current };
ids.forEach((id) => {
delete next[id];
});
return next;
});
if (expandedId && ids.includes(expandedId)) setExpandedId(null);
setSelectedIds([]);
await load();
};
let scheduleContent: React.ReactNode;
if (loading) {
scheduleContent = (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" />
加载计划中…
</div>
);
} else if (items.length === 0) {
scheduleContent = (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
暂无计划任务,点击“新建计划”开始。
</CardContent>
</Card>
);
} else {
scheduleContent = (
<div className="grid gap-3">
<div className="flex flex-col gap-3 rounded-lg border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative min-w-0 flex-1 sm:max-w-md">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => updateQuery(event.target.value)}
placeholder="检索计划名称、计划 ID 或任务 ID"
className="h-9 pl-8"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">每页</span>
<Select value={pageSize} onValueChange={updatePageSize}>
<SelectTrigger className="h-9 w-24 bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 条</SelectItem>
<SelectItem value="20">20 条</SelectItem>
<SelectItem value="50">50 条</SelectItem>
</SelectContent>
</Select>
<span className="text-muted-foreground">共 {filteredItems.length} 条</span>
</div>
</div>
{selectedIds.length ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<span className="text-sm">已选 {selectedIds.length} 个计划</span>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => void bulkRun()}>
<Zap data-icon="inline-start" />
立即运行
</Button>
<Button size="sm" variant="destructive" onClick={() => void bulkRemove()}>
<Trash2 data-icon="inline-start" />
删除
</Button>
<Button size="sm" variant="ghost" onClick={() => setSelectedIds([])}>
清空选择
</Button>
</div>
</div>
) : null}
{filteredItems.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">没有匹配的计划任务。</CardContent>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border bg-card">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-12">
<Checkbox
aria-label="选择当前页计划"
checked={headerSelectionState}
onCheckedChange={(checked) => toggleAllVisible(checked === true)}
/>
</TableHead>
<TableHead className="min-w-44">计划名称</TableHead>
<TableHead className="w-28">时间线</TableHead>
<TableHead className="min-w-48">运行周期</TableHead>
<TableHead className="min-w-40">开始时间</TableHead>
<TableHead className="min-w-40">结束时间</TableHead>
<TableHead className="min-w-24 text-center">运行次数</TableHead>
<TableHead className="min-w-36">绑定任务</TableHead>
<TableHead className="min-w-32">状态</TableHead>
<TableHead className="min-w-72 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleItems.map((item) => (
<React.Fragment key={item.id}>
<TableRow>
<TableCell className="w-12">
<Checkbox
aria-label={`选择计划 ${item.name}`}
checked={selectedIds.includes(item.id)}
onCheckedChange={(checked) => toggleSelected(item.id, checked === true)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{item.name}</div>
<div className="text-muted-foreground text-xs">计划 #{item.id}</div>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleTimeline(item.id)}>
{expandedId === item.id ? (
<ChevronDown data-icon="inline-start" />
) : (
<ChevronRight data-icon="inline-start" />
)}
{expandedId === item.id ? "收起" : "查看"}
</Button>
</TableCell>
<TableCell className="text-sm">{periodLabel(item)}</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{startLabel(item)}
{item.next_start ? (
<div className="mt-1 text-muted-foreground text-xs">
下次:{nextWindowLabel(item.next_start, item.timezone)}
</div>
) : null}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{endLabel(item)}
{item.next_end ? (
<div className="mt-1 text-muted-foreground text-xs">
下次:{nextWindowLabel(item.next_end, item.timezone)}
</div>
) : null}
</TableCell>
<TableCell className="text-center text-sm tabular-nums">
{item.run_count ?? 0}
{item.last_run_at ? (
<div className="mt-1 text-muted-foreground text-xs">最近 {formatTime(item.last_run_at)}</div>
) : null}
</TableCell>
<TableCell
className="max-w-52 truncate text-sm"
title={item.task_ids.map((id) => `#${id}`).join("、")}
>
{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}
</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(item.status)}>
{statusText(item)}
</Badge>
<div className="mt-1 text-muted-foreground text-xs">{timezoneLabel(item)}</div>
<div className="mt-1 text-muted-foreground text-xs">
最近:{formatTime(item.last_transition_at)}
</div>
{item.last_error ? (
<div
className="mt-1 max-w-40 truncate text-destructive text-xs"
title={scheduleErrorText(item.last_error)}
>
{scheduleErrorText(item.last_error)}
</div>
) : null}
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
setEditing({
id: item.id,
value: {
name: item.name,
enabled: item.enabled,
timezone_mode: item.timezone_mode,
schedule_type: item.schedule_type,
run_date: item.run_date,
end_date: item.end_date,
weekdays: item.weekdays,
start_time: item.start_time.slice(0, 5),
end_time: item.end_time?.slice(0, 5),
start_immediately: item.start_immediately,
task_ids: item.task_ids,
},
})
}
>
编辑
</Button>
{item.enabled ? (
<Button size="sm" variant="outline" onClick={() => void action(item.id, "pause")}>
<Pause data-icon="inline-start" />
暂停
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => void action(item.id, "resume")}>
<Play data-icon="inline-start" />
启用
</Button>
)}
<Button size="sm" variant="outline" onClick={() => void action(item.id, "run")}>
<Zap data-icon="inline-start" />
立即运行
</Button>
<Button size="sm" variant="ghost" onClick={() => void remove(item.id)}>
<Trash2 data-icon="inline-start" />
删除
</Button>
</div>
</TableCell>
</TableRow>
{expandedId === item.id ? (
<TableRow>
<TableCell colSpan={10} className="bg-muted/10 p-3">
<ScheduleTimeline schedule={timelineById[item.id]} loading={timelineLoadingId === item.id} />
</TableCell>
</TableRow>
) : null}
</React.Fragment>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((current) => current + 1)}
>
下一页
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-5 p-5">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 font-semibold text-xl">
<CalendarClock />
计划任务
</h1>
<p className="mt-1 text-muted-foreground text-sm">按北京时间或系统时区控制扫描任务的开始、暂停和恢复。</p>
</div>
<Button onClick={() => setEditing({ value: { ...blank, task_ids: [] } })}>
<Plus data-icon="inline-start" />
新建计划
</Button>
</div>
{editing && (
<Card>
<CardHeader>
<CardTitle>{editing.id ? "编辑计划" : "新建计划"}</CardTitle>
<CardDescription>计划结束时会暂停任务,下一次有效窗口自动继续。</CardDescription>
</CardHeader>
<CardContent>
<ScheduleForm
value={editing.value}
tasks={tasks}
saving={saving}
onCancel={() => setEditing(null)}
onSave={save}
/>
</CardContent>
</Card>
)}
{scheduleContent}
</div>
);
}
+144 -1
View File
@@ -45,10 +45,12 @@ import {
import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import { DateSelect } from "@/components/date-select";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { TaskTemplateControls } from "@/components/task-template-controls";
import { TimeSelect } from "@/components/time-select";
import {
AlertDialog,
AlertDialogAction,
@@ -118,7 +120,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { api, type ScheduleInput } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference";
import type {
@@ -3096,6 +3098,18 @@ function CreateTaskSheet({
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表)
const [scheduleEnabled, setScheduleEnabled] = React.useState(false);
const [schedule, setSchedule] = React.useState<ScheduleInput>({
name: "新建任务计划",
schedule_type: "weekly",
end_date: "",
weekdays: [6, 7],
start_time: "18:00",
end_time: "",
task_ids: [],
timezone_mode: "beijing",
start_immediately: false,
});
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
@@ -3162,6 +3176,7 @@ function CreateTaskSheet({
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
coverageEnabled,
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
interceptRules: interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
@@ -3180,6 +3195,18 @@ function CreateTaskSheet({
setSeedFirstIntent(false);
setCoverageEnabled(true);
setInterceptRules([]);
setScheduleEnabled(false);
setSchedule({
name: "新建任务计划",
schedule_type: "weekly",
end_date: "",
weekdays: [6, 7],
start_time: "18:00",
end_time: "",
task_ids: [],
timezone_mode: "beijing",
start_immediately: false,
});
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
@@ -3248,6 +3275,122 @@ function CreateTaskSheet({
/>
<FieldDescription>可选,单个分类;用于任务列表筛选和归档,不影响 Agent 执行。</FieldDescription>
</Field>
<FieldSet>
<FieldLegend>计划启动(可选)</FieldLegend>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={scheduleEnabled} onCheckedChange={(v) => setScheduleEnabled(!!v)} />
创建时附加计划,按时间窗口启动和暂停
</label>
{scheduleEnabled && (
<FieldGroup className="rounded-lg border p-3">
<Field>
<FieldLabel>计划名称</FieldLabel>
<Input value={schedule.name} onChange={(e) => setSchedule({ ...schedule, name: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel>规则</FieldLabel>
<Select
value={schedule.schedule_type}
onValueChange={(value) =>
setSchedule({ ...schedule, schedule_type: value as ScheduleInput["schedule_type"] })
}
>
<SelectTrigger className="h-10 w-full bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly">按周</SelectItem>
<SelectItem value="once">指定日期</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>时区</FieldLabel>
<Select
value={schedule.timezone_mode}
onValueChange={(value) =>
setSchedule({ ...schedule, timezone_mode: value as ScheduleInput["timezone_mode"] })
}
>
<SelectTrigger className="h-10 w-full bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beijing">北京时间</SelectItem>
<SelectItem value="system">系统时区</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
{schedule.schedule_type === "once" ? (
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel>开始日期</FieldLabel>
<DateSelect
value={schedule.run_date ?? ""}
onValueChange={(value) => setSchedule({ ...schedule, run_date: value })}
aria-label="开始日期"
/>
</Field>
<Field>
<FieldLabel>截止日期(可选)</FieldLabel>
<DateSelect
value={schedule.end_date ?? ""}
onValueChange={(value) => setSchedule({ ...schedule, end_date: value })}
optional
placeholder="不设置截止日期"
aria-label="截止日期,可选"
/>
</Field>
</div>
) : (
<Field>
<FieldLabel>星期(1=周一,7=周日)</FieldLabel>
<Input
value={(schedule.weekdays ?? []).join(",")}
onChange={(e) =>
setSchedule({
...schedule,
weekdays: e.target.value
.split(",")
.map(Number)
.filter((n) => n >= 1 && n <= 7),
})
}
placeholder="例如 6,7"
/>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel>开始时间</FieldLabel>
<TimeSelect
value={schedule.start_time}
onValueChange={(value) => setSchedule({ ...schedule, start_time: value })}
aria-label="开始时间"
/>
</Field>
<Field>
<FieldLabel>结束时间(可选)</FieldLabel>
<TimeSelect
value={schedule.end_time ?? ""}
onValueChange={(value) => setSchedule({ ...schedule, end_time: value })}
optional
aria-label="结束时间,可选"
/>
</Field>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={schedule.start_immediately}
onCheckedChange={(v) => setSchedule({ ...schedule, start_immediately: !!v })}
/>
创建后立即启动,否则等待第一个计划窗口
</label>
</FieldGroup>
)}
</FieldSet>
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
+88
View File
@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale/zh-CN";
import { CalendarDays, ChevronDown, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
function parseDate(value?: string) {
if (!value) return undefined;
const parsed = parse(value, "yyyy-MM-dd", new Date());
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
export function DateSelect({
value,
onValueChange,
optional = false,
placeholder = "选择日期",
"aria-label": ariaLabel,
}: {
value?: string;
onValueChange: (value: string) => void;
optional?: boolean;
placeholder?: string;
"aria-label"?: string;
}) {
const [open, setOpen] = React.useState(false);
const selected = parseDate(value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="h-10 w-full justify-between bg-background px-3 font-normal"
aria-label={ariaLabel}
aria-expanded={open}
>
<span className="flex items-center gap-2">
<CalendarDays className="size-4 text-muted-foreground" />
{selected ? (
format(selected, "yyyy年M月d日", { locale: zhCN })
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</span>
<span className="flex items-center gap-1">
<ChevronDown className="size-4 text-muted-foreground" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto overflow-hidden p-0">
<Calendar
mode="single"
locale={zhCN}
captionLayout="dropdown"
startMonth={new Date(2020, 0, 1)}
endMonth={new Date(2100, 11, 31)}
selected={selected}
defaultMonth={selected ?? new Date()}
onSelect={(date) => {
onValueChange(date ? format(date, "yyyy-MM-dd") : "");
setOpen(false);
}}
/>
{optional && selected && (
<Button
type="button"
variant="ghost"
className="w-full rounded-none border-t"
onClick={() => {
onValueChange("");
setOpen(false);
}}
>
<X data-icon="inline-start" />
清除日期
</Button>
)}
</PopoverContent>
</Popover>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
const hours = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0"));
const minutes = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0"));
function splitTime(value?: string) {
const match = value?.slice(0, 5).match(/^(\d{2}):(\d{2})$/);
return match ? { hour: match[1], minute: match[2] } : { hour: undefined, minute: undefined };
}
function TimePart({
value,
items,
max,
placeholder,
ariaLabel,
onValueChange,
}: {
value?: string;
items: string[];
max: number;
placeholder: string;
ariaLabel: string;
onValueChange: (value: string) => void;
}) {
const [inputValue, setInputValue] = React.useState(value ?? "");
React.useEffect(() => setInputValue(value ?? ""), [value]);
const commit = (raw: string) => {
const digits = raw.replace(/\D/g, "").slice(0, 2);
if (!digits) {
setInputValue("");
return;
}
const number = Number(digits);
if (number > max) {
setInputValue(value ?? "");
return;
}
const next = digits.padStart(2, "0");
setInputValue(next);
onValueChange(next);
};
return (
<Combobox
items={items}
itemToStringLabel={(item) => item}
itemToStringValue={(item) => item}
value={value ?? null}
inputValue={inputValue}
onInputValueChange={(next) => setInputValue(next.replace(/\D/g, "").slice(0, 2))}
onValueChange={(next) => {
if (typeof next === "string") {
setInputValue(next);
onValueChange(next);
}
}}
>
<ComboboxInput
className="h-10 min-w-0"
placeholder={placeholder}
aria-label={ariaLabel}
inputMode="numeric"
maxLength={2}
onBlur={() => commit(inputValue)}
onKeyDown={(event) => {
if (event.key === "Enter") commit(inputValue);
}}
/>
<ComboboxContent className="max-h-64" sideOffset={4}>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
export function TimeSelect({
value,
onValueChange,
optional = false,
placeholder = "选择时间",
"aria-label": ariaLabel,
}: {
value?: string;
onValueChange: (value: string) => void;
optional?: boolean;
placeholder?: string;
"aria-label"?: string;
}) {
const selected = splitTime(value);
const hasValue = Boolean(selected.hour && selected.minute);
const updateHour = (hour: string) => onValueChange(`${hour}:${selected.minute ?? "00"}`);
const updateMinute = (minute: string) => onValueChange(`${selected.hour ?? "00"}:${minute}`);
return (
<div className="flex min-w-0 items-center gap-1" title={optional && !hasValue ? placeholder : undefined}>
<TimePart
value={selected.hour}
items={hours}
max={23}
placeholder="小时"
ariaLabel={ariaLabel ? `${ariaLabel} 小时` : "小时"}
onValueChange={updateHour}
/>
<span className="font-medium text-muted-foreground">:</span>
<TimePart
value={selected.minute}
items={minutes}
max={59}
placeholder="分钟"
ariaLabel={ariaLabel ? `${ariaLabel} 分钟` : "分钟"}
onValueChange={updateMinute}
/>
{optional && hasValue && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={() => onValueChange("")}
aria-label="清除结束时间"
title="不设置结束时间"
>
<X />
</Button>
)}
</div>
);
}
+42
View File
@@ -84,6 +84,7 @@ import type {
SSTask,
Stats,
Task,
TaskSchedule,
TaskArchive,
TaskArchivePage,
TaskAssetMutation,
@@ -245,6 +246,36 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
return query.toString();
}
export type ScheduleInput = {
name: string;
enabled?: boolean;
timezone_mode?: "beijing" | "system";
schedule_type: "once" | "weekly";
run_date?: string;
end_date?: string;
weekdays?: number[];
start_time: string;
end_time?: string;
start_immediately?: boolean;
task_ids: number[];
};
function schedulePayload(input: ScheduleInput) {
return {
name: input.name,
enabled: input.enabled ?? true,
timezone_mode: input.timezone_mode ?? "beijing",
schedule_type: input.schedule_type,
run_date: input.run_date ?? "",
end_date: input.end_date ?? "",
weekdays: input.weekdays ?? [],
start_time: input.start_time,
end_time: input.end_time ?? "",
start_immediately: input.start_immediately ?? false,
task_ids: input.task_ids,
};
}
export const api = {
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
@@ -273,6 +304,7 @@ export const api = {
planHeartbeatSeconds?: number;
coverageEnabled?: boolean;
interceptRules?: AssetInterceptRuleInput[];
schedule?: ScheduleInput;
}) =>
post<Task>("/tasks", {
name: input.name ?? "",
@@ -287,7 +319,17 @@ export const api = {
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
}),
schedules: () => get<{ schedules: TaskSchedule[] }>("/schedules").then((r) => arr(r.schedules)),
schedule: (id: number) => get<TaskSchedule>(`/schedules/${id}`),
createSchedule: (input: ScheduleInput) => post<TaskSchedule>("/schedules", schedulePayload(input)),
updateSchedule: (id: number, input: ScheduleInput) => patch<TaskSchedule>(`/schedules/${id}`, schedulePayload(input)),
deleteSchedule: (id: number) => del<{ ok: boolean }>(`/schedules/${id}`),
pauseSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/pause`, {}),
resumeSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/resume`, {}),
runScheduleNow: (id: number) =>
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
+32
View File
@@ -715,6 +715,38 @@ export interface AgentTrigger {
last_fire?: string;
}
export interface TaskSchedule {
id: number;
name: string;
enabled: boolean;
timezone_mode: "beijing" | "system";
timezone?: string;
schedule_type: "once" | "weekly";
run_date?: string;
end_date?: string;
weekdays: number[];
start_time: string;
end_time?: string;
start_immediately: boolean;
status: "waiting" | "running" | "outside" | "completed" | "error" | "paused";
last_window_key?: string;
last_transition_at?: string;
last_error?: string;
next_start?: string;
next_end?: string;
run_count?: number;
last_run_at?: string;
task_ids: number[];
history?: Array<{
id: number;
task_id?: number;
action: string;
success: boolean;
message: string;
created_at: string;
}>;
}
// ---- Conversations (chat page) ----
export interface ActiveFindingRetest {
id: number;
@@ -2,6 +2,7 @@ import {
Activity,
Ban,
BellRing,
CalendarClock,
Bot,
Brain,
Bug,
@@ -69,6 +70,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "dashboard", title: "仪表盘", url: "/dashboard", icon: LayoutDashboard },
{ id: "chat", title: "对话", url: "/chat", icon: MessageSquare },
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
{ id: "schedules", title: "计划任务", url: "/function/schedules", icon: CalendarClock },
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },