0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
+131
View File
@@ -0,0 +1,131 @@
package scheduler
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// Cron implements the standard five-field cron format: minute, hour,
// day-of-month, month, day-of-week. Lists, ranges and steps are supported.
type Cron struct {
minute, hour, day, month, weekday field
dayWildcard, weekdayWildcard bool
}
type field struct {
min, max int
values map[int]bool
}
func ParseCron(expression string) (Cron, error) {
parts := strings.Fields(expression)
if len(parts) != 5 {
return Cron{}, errors.New("cron 表达式必须包含 5 段: 分 时 日 月 周")
}
definitions := [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
fields := make([]field, 5)
for i, part := range parts {
parsed, err := parseField(part, definitions[i][0], definitions[i][1], i == 4)
if err != nil {
return Cron{}, fmt.Errorf("cron 第 %d 段无效: %w", i+1, err)
}
fields[i] = parsed
}
return Cron{minute: fields[0], hour: fields[1], day: fields[2], month: fields[3], weekday: fields[4], dayWildcard: parts[2] == "*", weekdayWildcard: parts[4] == "*"}, nil
}
func parseField(raw string, minimum, maximum int, weekday bool) (field, error) {
result := field{min: minimum, max: maximum, values: map[int]bool{}}
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(item)
if item == "" {
return field{}, errors.New("存在空列表项")
}
base, stepText, hasStep := strings.Cut(item, "/")
step := 1
if hasStep {
var err error
step, err = strconv.Atoi(stepText)
if err != nil || step < 1 || step > maximum-minimum+1 {
return field{}, errors.New("步长无效")
}
}
start, end := minimum, maximum
switch {
case base == "*":
case strings.Contains(base, "-"):
left, right, _ := strings.Cut(base, "-")
var err error
start, err = cronNumber(left, minimum, maximum, weekday)
if err != nil {
return field{}, err
}
end, err = cronNumber(right, minimum, maximum, weekday)
if err != nil || start > end {
return field{}, errors.New("范围无效")
}
default:
var err error
start, err = cronNumber(base, minimum, maximum, weekday)
if err != nil {
return field{}, err
}
end = start
if hasStep {
end = maximum
}
}
for value := start; value <= end; value += step {
if weekday && value == 7 {
value = 0
result.values[value] = true
break
}
result.values[value] = true
}
}
if len(result.values) == 0 {
return field{}, errors.New("没有可用取值")
}
return result, nil
}
func cronNumber(raw string, minimum, maximum int, weekday bool) (int, error) {
value, err := strconv.Atoi(raw)
if err != nil || value < minimum || value > maximum {
return 0, fmt.Errorf("%q 超出 %d-%d", raw, minimum, maximum)
}
if weekday && value == 7 {
return 7, nil
}
return value, nil
}
func (c Cron) Matches(value time.Time) bool {
dayMatch := c.day.values[value.Day()]
weekdayMatch := c.weekday.values[int(value.Weekday())]
calendarMatch := dayMatch && weekdayMatch
// Vixie cron semantics: when both day fields are restricted, either may match.
if !c.dayWildcard && !c.weekdayWildcard {
calendarMatch = dayMatch || weekdayMatch
}
return c.minute.values[value.Minute()] && c.hour.values[value.Hour()] && c.month.values[int(value.Month())] && calendarMatch
}
func (c Cron) Next(after time.Time, location *time.Location) (time.Time, error) {
if location == nil {
location = time.UTC
}
candidate := after.UTC().Truncate(time.Minute).Add(time.Minute)
deadline := candidate.AddDate(5, 0, 0)
for candidate.Before(deadline) {
if c.Matches(candidate.In(location)) {
return candidate, nil
}
candidate = candidate.Add(time.Minute)
}
return time.Time{}, errors.New("未来 5 年内无匹配执行时间")
}
+35
View File
@@ -0,0 +1,35 @@
package scheduler
import (
"testing"
"time"
)
func TestCronNext(t *testing.T) {
cases := []struct {
expression, after, want string
}{
{"*/15 * * * *", "2026-08-12T10:07:00Z", "2026-08-12T10:15:00Z"},
{"0 9 * * 1-5", "2026-08-14T09:01:00Z", "2026-08-17T09:00:00Z"},
{"30 8 1 * *", "2026-08-12T00:00:00Z", "2026-09-01T08:30:00Z"},
}
for _, tc := range cases {
schedule, err := ParseCron(tc.expression)
if err != nil {
t.Fatal(err)
}
after, _ := time.Parse(time.RFC3339, tc.after)
got, err := schedule.Next(after, time.UTC)
if err != nil || got.Format(time.RFC3339) != tc.want {
t.Errorf("%s next=%s err=%v want=%s", tc.expression, got.Format(time.RFC3339), err, tc.want)
}
}
}
func TestCronRejectsInvalid(t *testing.T) {
for _, expression := range []string{"* * *", "60 * * * *", "*/0 * * * *", "* 24 * * *"} {
if _, err := ParseCron(expression); err == nil {
t.Errorf("expected %q to be rejected", expression)
}
}
}
+403
View File
@@ -0,0 +1,403 @@
package scheduler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"unicode/utf8"
platformid "aigateway.local/core/internal/platform/id"
)
const maxExecutionResponseBytes = 2 << 20
type Engine struct {
service *Service
baseURL string
client *http.Client
workerID string
batchSize int
maxAttempts int
executionTTL time.Duration
logger *slog.Logger
}
func NewEngine(service *Service, baseURL, workerID string, batchSize, maxAttempts int, executionTTL time.Duration, logger *slog.Logger) *Engine {
if batchSize < 1 {
batchSize = 10
}
if maxAttempts < 1 {
maxAttempts = 3
}
if executionTTL < time.Minute {
executionTTL = 5 * time.Minute
}
if logger == nil {
logger = slog.Default()
}
return &Engine{service: service, baseURL: strings.TrimRight(baseURL, "/"), client: &http.Client{Timeout: executionTTL}, workerID: workerID, batchSize: batchSize, maxAttempts: maxAttempts, executionTTL: executionTTL, logger: logger}
}
// Tick coalesces overdue schedules into durable pending runs, reclaims stale
// executions, and processes a bounded batch. SKIP LOCKED makes it safe for
// multiple scheduler replicas to call Tick concurrently.
func (e *Engine) Tick(ctx context.Context, now time.Time) (int, error) {
if err := e.scheduleDue(ctx, now.UTC()); err != nil {
return 0, err
}
if err := e.reclaim(ctx, now.UTC()); err != nil {
return 0, err
}
runs, err := e.claim(ctx)
if err != nil {
return 0, err
}
for _, run := range runs {
task, getErr := e.service.Get(ctx, run.TaskID)
if getErr != nil {
_ = e.complete(ctx, run, Task{ID: run.TaskID, Code: run.TaskCode}, nil, getErr)
continue
}
response, executeErr := e.execute(ctx, task, run)
if executeErr != nil && run.Attempts < e.maxAttempts {
if retryErr := e.retry(ctx, run, executeErr); retryErr != nil {
return len(runs), retryErr
}
continue
}
if completeErr := e.complete(ctx, run, task, response, executeErr); completeErr != nil {
return len(runs), completeErr
}
}
return len(runs), nil
}
func (e *Engine) scheduleDue(ctx context.Context, now time.Time) error {
tx, err := e.service.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `SELECT id::text,cron_expression,timezone,next_run_at FROM gateway.scheduled_tasks WHERE enabled AND next_run_at <= $1 ORDER BY next_run_at,id FOR UPDATE SKIP LOCKED LIMIT $2`, now, e.batchSize)
if err != nil {
return err
}
type due struct {
id, expression, timezone string
scheduledFor time.Time
}
items := []due{}
for rows.Next() {
var item due
if err = rows.Scan(&item.id, &item.expression, &item.timezone, &item.scheduledFor); err != nil {
rows.Close()
return err
}
items = append(items, item)
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
for _, item := range items {
schedule, parseErr := ParseCron(item.expression)
location, locationErr := time.LoadLocation(item.timezone)
if parseErr != nil || locationErr != nil {
_, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET enabled=false,next_run_at=NULL,last_status='failed',last_error='cron 或时区配置无效',updated_at=clock_timestamp() WHERE id=$1`, item.id)
if err != nil {
return err
}
continue
}
// 补跑停机期间漏掉的执行:从旧的 next_run_at 起逐个 occurrence
// 落一条 pending run,直到越过 now,而不是只补最新一次。否则调度器
// 宕机超过一个周期后,中间所有计划执行被静默丢弃。
runID, idErr := platformid.NewUUID()
if idErr != nil {
return idErr
}
if _, err = tx.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'scheduled',$3) ON CONFLICT(task_id,trigger_type,scheduled_for) DO NOTHING`, runID, item.id, item.scheduledFor); err != nil {
return err
}
nextRun := item.scheduledFor
var nextErr error
inserted := 1
for nextRun.Before(now) || nextRun.Equal(now) {
if inserted >= catchUpLimit {
break
}
nextRun, nextErr = schedule.Next(nextRun, location)
if nextErr != nil {
return nextErr
}
if nextRun.After(now) {
break
}
runID, idErr = platformid.NewUUID()
if idErr != nil {
return idErr
}
if _, err = tx.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'scheduled',$3) ON CONFLICT(task_id,trigger_type,scheduled_for) DO NOTHING`, runID, item.id, nextRun); err != nil {
return err
}
inserted++
}
next, nextErr := schedule.Next(now, location)
if nextErr != nil {
return nextErr
}
if _, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET next_run_at=$2,updated_at=clock_timestamp() WHERE id=$1`, item.id, next); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// catchUpLimit 是单任务单次补跑的最大执行数;超过部分丢弃,防止调度器长期
// 停机后瞬间插入海量补跑记录。
const catchUpLimit = 100
func (e *Engine) reclaim(ctx context.Context, now time.Time) error {
cutoff := now.Add(-e.executionTTL)
tx, err := e.service.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='pending',worker_id='',started_at=NULL,error='上次执行超时,已回收重试' WHERE status='running' AND started_at < $1 AND attempts < $2`, cutoff, e.maxAttempts); err != nil {
return err
}
rows, err := tx.Query(ctx, `WITH picked AS (SELECT id FROM gateway.scheduled_task_runs WHERE status='running' AND started_at < $1 AND attempts >= $2 ORDER BY started_at,id FOR UPDATE SKIP LOCKED LIMIT $3) UPDATE gateway.scheduled_task_runs r SET worker_id=$4,started_at=$5 FROM picked WHERE r.id=picked.id RETURNING r.id::text`, cutoff, e.maxAttempts, e.batchSize, e.workerID, now)
if err != nil {
return err
}
ids := []string{}
for rows.Next() {
var id string
if err = rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
if err = tx.Commit(ctx); err != nil {
return err
}
for _, id := range ids {
run, getErr := e.service.getRun(ctx, id)
if getErr != nil {
return getErr
}
task, taskErr := e.service.Get(ctx, run.TaskID)
if taskErr != nil {
task = Task{ID: run.TaskID, Code: run.TaskCode}
}
if err = e.complete(ctx, run, task, nil, errors.New("执行超时且达到最大重试次数")); err != nil {
return err
}
}
return nil
}
func (e *Engine) claim(ctx context.Context) ([]Run, error) {
tx, err := e.service.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `WITH picked AS (SELECT id FROM gateway.scheduled_task_runs WHERE status='pending' ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT $1) UPDATE gateway.scheduled_task_runs r SET status='running',attempts=attempts+1,worker_id=$2,started_at=clock_timestamp(),error='' FROM picked WHERE r.id=picked.id RETURNING r.id::text`, e.batchSize, e.workerID)
if err != nil {
return nil, err
}
ids := []string{}
for rows.Next() {
var id string
if err = rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
rows.Close()
if err = rows.Err(); err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
runs := make([]Run, 0, len(ids))
for _, id := range ids {
run, getErr := e.service.getRun(ctx, id)
if getErr != nil {
return nil, getErr
}
runs = append(runs, run)
}
return runs, nil
}
func (e *Engine) conversationMessages(ctx context.Context, task Task, currentRunID string) ([]map[string]any, error) {
messages := []map[string]any{}
if task.ConversationID != "" {
rows, err := e.service.pool.Query(ctx, `SELECT response FROM gateway.scheduled_task_runs WHERE task_id=$1 AND id<>$2 AND status='success' AND response IS NOT NULL ORDER BY created_at DESC LIMIT 5`, task.ID, currentRunID)
if err != nil {
return nil, err
}
defer rows.Close()
answers := []string{}
for rows.Next() {
var response json.RawMessage
if err = rows.Scan(&response); err != nil {
return nil, err
}
if answer := responseAnswer(response); answer != "" {
answers = append(answers, answer)
}
}
for i := len(answers) - 1; i >= 0; i-- {
messages = append(messages, map[string]any{"role": "user", "content": task.Prompt}, map[string]any{"role": "assistant", "content": answers[i]})
}
}
messages = append(messages, map[string]any{"role": "user", "content": task.Prompt})
return messages, nil
}
func responseAnswer(raw json.RawMessage) string {
var response map[string]any
if json.Unmarshal(raw, &response) != nil {
return ""
}
choices, _ := response["choices"].([]any)
if len(choices) == 0 {
return ""
}
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
answer, _ := message["content"].(string)
return answer
}
func (e *Engine) execute(ctx context.Context, task Task, run Run) (json.RawMessage, error) {
secret, err := e.service.decryptAPIKey(task)
if err != nil {
return nil, fmt.Errorf("解密执行 API Key: %w", err)
}
messages, err := e.conversationMessages(ctx, task, run.ID)
if err != nil {
return nil, err
}
var variables map[string]any
if json.Unmarshal(task.Variables, &variables) != nil {
variables = map[string]any{}
}
payload := map[string]any{"messages": messages, "variables": variables}
path := "/v1/applications/" + task.TargetCode + "/chat/completions"
if task.TargetType == "digital_employee" {
path = "/v1/digital-employees/" + task.TargetCode + "/chat/completions"
payload["skill_ids"] = task.SkillIDs
payload["mcp_server_ids"] = task.MCPServerIDs
}
body, _ := json.Marshal(payload)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gateway-API-Key", secret)
request.Header.Set("X-Request-ID", "scheduled-"+run.ID)
response, err := e.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, maxExecutionResponseBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxExecutionResponseBytes {
return nil, errors.New("模型响应超过 2MB 上限")
}
if !json.Valid(data) {
return nil, errors.New("模型响应不是合法 JSON")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("模型调用失败(HTTP %d: %s", response.StatusCode, truncate(string(data), 1000))
}
return json.RawMessage(data), nil
}
func truncate(value string, maximum int) string {
value = strings.TrimSpace(value)
if len(value) <= maximum {
return value
}
cut := value[:maximum]
// 按字节截断可能把多字节 rune 切半,产生无效 UTF-8;PostgreSQL text 列
// 会拒绝写入,导致任务永远停在重试循环。回退到最近的 rune 边界。
for len(cut) > 0 && !utf8.RuneStart(cut[len(cut)-1]) {
cut = cut[:len(cut)-1]
}
return cut
}
func (e *Engine) retry(ctx context.Context, run Run, executeErr error) error {
errorText := truncate(executeErr.Error(), 4000)
tag, err := e.service.pool.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='pending',worker_id='',started_at=NULL,response=NULL,error=$2 WHERE id=$1 AND status='running' AND worker_id=$3`, run.ID, errorText, e.workerID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("定时任务执行租约已失效")
}
e.logger.Warn("scheduled task execution will retry", "task", run.TaskCode, "run_id", run.ID, "attempt", run.Attempts, "error", executeErr)
return nil
}
func (e *Engine) complete(ctx context.Context, run Run, task Task, response json.RawMessage, executeErr error) error {
status := "success"
errorText := ""
eventType := "scheduled_task.completed"
if executeErr != nil {
status = "failed"
eventType = "scheduled_task.failed"
errorText = truncate(executeErr.Error(), 4000)
e.logger.Warn("scheduled task execution failed", "task", task.Code, "run_id", run.ID, "error", executeErr)
}
tx, err := e.service.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status=$2,response=$3,error=$4,finished_at=clock_timestamp() WHERE id=$1 AND status='running' AND worker_id=$5`, run.ID, status, response, errorText, e.workerID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("定时任务执行租约已失效")
}
_, err = tx.Exec(ctx, `UPDATE gateway.scheduled_tasks SET last_run_at=clock_timestamp(),last_status=$2,last_error=$3,updated_at=clock_timestamp() WHERE id=$1`, task.ID, status, errorText)
if err != nil {
return err
}
eventID, err := platformid.NewUUID()
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]any{"scheduled_task_id": task.ID, "task_code": task.Code, "run_id": run.ID, "status": status, "error": errorText, "actor_id": task.CreatedBy, "notification_channel_id": task.NotificationChannelID})
_, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'scheduled_task',$3,$4)`, eventID, eventType, run.ID, payload)
if err != nil {
return err
}
return tx.Commit(ctx)
}
+173
View File
@@ -0,0 +1,173 @@
package scheduler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
)
type AdminHTTPHandler struct {
service *Service
identity *identity.Service
mux *http.ServeMux
}
func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler {
h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks", h.list)
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks", h.create)
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks/{id}", h.get)
h.mux.HandleFunc("PUT /api/v1/admin/scheduled-tasks/{id}", h.update)
h.mux.HandleFunc("DELETE /api/v1/admin/scheduled-tasks/{id}", h.delete)
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/start", h.start)
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/pause", h.pause)
h.mux.HandleFunc("POST /api/v1/admin/scheduled-tasks/{id}/run", h.runNow)
h.mux.HandleFunc("GET /api/v1/admin/scheduled-tasks/{id}/runs", h.runs)
return h
}
func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *AdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) {
account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效")
return identity.Account{}, false
}
if !identity.HasPermission(account, permission) {
apiresponse.Error(w, http.StatusForbidden, "缺少定时任务权限")
return identity.Account{}, false
}
return account, true
}
func decodeTask(w http.ResponseWriter, r *http.Request, target any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
return false
}
return true
}
func taskError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) {
apiresponse.Error(w, http.StatusNotFound, "定时任务不存在")
return
}
apiresponse.Error(w, http.StatusBadRequest, err.Error())
}
func (h *AdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
return
}
items, err := h.service.List(r.Context())
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, items)
}
func (h *AdminHTTPHandler) get(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
return
}
item, err := h.service.Get(r.Context(), r.PathValue("id"))
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, item)
}
func (h *AdminHTTPHandler) create(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionScheduledTaskManage)
if !ok {
return
}
var input TaskInput
if !decodeTask(w, r, &input) {
return
}
item, err := h.service.Save(r.Context(), "", input, account.ID)
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, item)
}
func (h *AdminHTTPHandler) update(w http.ResponseWriter, r *http.Request) {
account, ok := h.require(w, r, identity.PermissionScheduledTaskManage)
if !ok {
return
}
var input TaskInput
if !decodeTask(w, r, &input) {
return
}
item, err := h.service.Save(r.Context(), r.PathValue("id"), input, account.ID)
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, item)
}
func (h *AdminHTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
return
}
if err := h.service.Delete(r.Context(), r.PathValue("id")); err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, map[string]bool{"deleted": true})
}
func (h *AdminHTTPHandler) setEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
return
}
item, err := h.service.SetEnabled(r.Context(), r.PathValue("id"), enabled)
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, item)
}
func (h *AdminHTTPHandler) start(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, true) }
func (h *AdminHTTPHandler) pause(w http.ResponseWriter, r *http.Request) { h.setEnabled(w, r, false) }
func (h *AdminHTTPHandler) runNow(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskManage); !ok {
return
}
run, err := h.service.QueueManual(r.Context(), r.PathValue("id"))
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, run)
}
func (h *AdminHTTPHandler) runs(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionScheduledTaskRead); !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.service.Runs(r.Context(), r.PathValue("id"), limit)
if err != nil {
taskError(w, err)
return
}
apiresponse.OK(w, items)
}
@@ -0,0 +1,168 @@
package scheduler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/platform/database"
)
func TestSchedulerPostgreSQLLifecycle(t *testing.T) {
databaseURL := os.Getenv("SCHEDULER_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("SCHEDULER_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8})
if err != nil {
t.Fatal(err)
}
defer pool.Close()
adminID := "64444444-4444-4444-8444-444444444444"
appID := "65555555-5555-4555-8555-555555555555"
versionID := "66666666-6666-4666-8666-666666666666"
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM gateway.scheduled_tasks WHERE code='scheduler_test_task'`)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.applications WHERE id=$1`, appID)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1`, adminID)
}
cleanup()
defer cleanup()
if _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role,active) VALUES($1,'scheduler-test-admin','test','superadmin',true)`, adminID); err != nil {
t.Fatal(err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
configJSON := `{"model":"test-model","knowledge_base_ids":[],"tool_ids":[],"retrieval_top_k":4,"temperature":0.2,"max_tool_rounds":1}`
if _, err = tx.Exec(ctx, `INSERT INTO gateway.applications(id,code,name,status,draft_config,created_by) VALUES($1,'scheduler_test_app','Scheduler Test App','active',$2,$3)`, appID, configJSON, adminID); err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, `INSERT INTO gateway.application_versions(id,application_id,version,config,published_by) VALUES($1,$2,1,$3,$4)`, versionID, appID, configJSON, adminID); err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, `UPDATE gateway.applications SET published_version=1 WHERE id=$1`, appID); err != nil {
t.Fatal(err)
}
if err = tx.Commit(ctx); err != nil {
t.Fatal(err)
}
requestCount := 0
failRequests := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.URL.Path != "/v1/applications/scheduler_test_app/chat/completions" || r.Header.Get("X-Gateway-API-Key") != "gw_scheduler_test" {
http.Error(w, "unexpected request", http.StatusUnauthorized)
return
}
if failRequests {
http.Error(w, `{"error":"temporary failure"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"scheduled answer"}}]}`))
}))
defer server.Close()
cipher, err := cryptox.NewKeyring("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 1, "", "scheduled-task-api-key")
if err != nil {
t.Fatal(err)
}
service := NewService(pool, cipher)
task, err := service.Save(ctx, "", TaskInput{Code: "scheduler_test_task", Name: "Scheduler Test", CronExpression: "*/5 * * * *", Timezone: "UTC", TargetType: "application", TargetCode: "scheduler_test_app", Prompt: "create report", Variables: json.RawMessage(`{"scope":"daily"}`), APIKey: "gw_scheduler_test"}, adminID)
if err != nil {
t.Fatal(err)
}
if !task.HasAPIKey || task.Enabled {
t.Fatalf("unexpected task: %+v", task)
}
if _, err = service.QueueManual(ctx, task.ID); err != nil {
t.Fatal(err)
}
engine := NewEngine(service, server.URL, "integration-worker", 10, 3, time.Minute, nil)
processed, err := engine.Tick(ctx, time.Now())
if err != nil || processed != 1 || requestCount != 1 {
t.Fatalf("tick processed=%d requests=%d err=%v", processed, requestCount, err)
}
runs, err := service.Runs(ctx, task.ID, 10)
if err != nil || len(runs) != 1 || runs[0].Status != "success" || responseAnswer(runs[0].Response) != "scheduled answer" {
t.Fatalf("runs=%+v err=%v", runs, err)
}
var completed bool
if err = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.outbox_events WHERE aggregate_type='scheduled_task' AND aggregate_id=$1 AND event_type='scheduled_task.completed')`, runs[0].ID).Scan(&completed); err != nil || !completed {
t.Fatalf("completion event=%v err=%v", completed, err)
}
// Force a due schedule in the past. Tick must enqueue it once, execute it,
// and advance next_run_at beyond now rather than replay every missed slot.
if _, err = service.SetEnabled(ctx, task.ID, true); err != nil {
t.Fatal(err)
}
forcedNow := time.Now().UTC()
if _, err = pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET next_run_at=$2 WHERE id=$1`, task.ID, forcedNow.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
processed, err = engine.Tick(ctx, forcedNow)
if err != nil || processed != 1 || requestCount != 2 {
t.Fatalf("due tick processed=%d requests=%d err=%v", processed, requestCount, err)
}
task, err = service.Get(ctx, task.ID)
if err != nil || task.NextRunAt == nil || !task.NextRunAt.After(forcedNow) {
t.Fatalf("next run was not advanced: %+v err=%v", task.NextRunAt, err)
}
// Ordinary gateway failures remain pending until the configured attempt
// limit, then become failed and emit exactly one terminal event.
if _, err = service.SetEnabled(ctx, task.ID, false); err != nil {
t.Fatal(err)
}
failRequests = true
failedRun, err := service.QueueManual(ctx, task.ID)
if err != nil {
t.Fatal(err)
}
for attempt := 1; attempt <= 3; attempt++ {
processed, err = engine.Tick(ctx, time.Now())
if err != nil || processed != 1 {
t.Fatalf("retry tick attempt=%d processed=%d err=%v", attempt, processed, err)
}
current, getErr := service.getRun(ctx, failedRun.ID)
wantStatus := "pending"
if attempt == 3 {
wantStatus = "failed"
}
if getErr != nil || current.Status != wantStatus || current.Attempts != attempt {
t.Fatalf("retry attempt=%d run=%+v err=%v", attempt, current, getErr)
}
}
var failedEvents int
if err = pool.QueryRow(ctx, `SELECT count(*) FROM gateway.outbox_events WHERE aggregate_type='scheduled_task' AND aggregate_id=$1 AND event_type='scheduled_task.failed'`, failedRun.ID).Scan(&failedEvents); err != nil || failedEvents != 1 {
t.Fatalf("failure events=%d err=%v", failedEvents, err)
}
// A worker lease that stays running past its timeout is finalized through
// the same failed-run and outbox path once it reaches the attempt limit.
staleRun, err := service.QueueManual(ctx, task.ID)
if err != nil {
t.Fatal(err)
}
if _, err = pool.Exec(ctx, `UPDATE gateway.scheduled_task_runs SET status='running',attempts=3,worker_id='dead-worker',started_at=$2 WHERE id=$1`, staleRun.ID, time.Now().Add(-2*time.Minute)); err != nil {
t.Fatal(err)
}
processed, err = engine.Tick(ctx, time.Now())
if err != nil || processed != 0 {
t.Fatalf("stale tick processed=%d err=%v", processed, err)
}
staleRun, err = service.getRun(ctx, staleRun.ID)
if err != nil || staleRun.Status != "failed" || staleRun.Error != "执行超时且达到最大重试次数" {
t.Fatalf("stale run=%+v err=%v", staleRun, err)
}
}
+441
View File
@@ -0,0 +1,441 @@
package scheduler
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"aigateway.local/core/internal/apikey"
"aigateway.local/core/internal/platform/cryptox"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound = errors.New("scheduled task not found")
codePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
)
type Task struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
CronExpression string `json:"cron_expression"`
Timezone string `json:"timezone"`
TargetType string `json:"target_type"`
TargetCode string `json:"target_code"`
Prompt string `json:"prompt"`
Variables json.RawMessage `json:"variables"`
SkillIDs []string `json:"skill_ids"`
MCPServerIDs []string `json:"mcp_server_ids"`
ConversationID string `json:"conversation_id"`
NotificationChannelID *string `json:"notification_channel_id,omitempty"`
HasAPIKey bool `json:"has_api_key"`
Enabled bool `json:"enabled"`
NextRunAt *time.Time `json:"next_run_at,omitempty"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status"`
LastError string `json:"last_error"`
CreatedBy string `json:"created_by"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
encryptedAPIKey []byte
apiKeyKEKVersion int
}
type TaskInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
CronExpression string `json:"cron_expression"`
Timezone string `json:"timezone"`
TargetType string `json:"target_type"`
TargetCode string `json:"target_code"`
Prompt string `json:"prompt"`
Variables json.RawMessage `json:"variables"`
SkillIDs []string `json:"skill_ids"`
MCPServerIDs []string `json:"mcp_server_ids"`
ConversationID string `json:"conversation_id"`
NotificationChannelID *string `json:"notification_channel_id"`
APIKey string `json:"api_key"`
Enabled bool `json:"enabled"`
}
type Run struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
TaskCode string `json:"task_code"`
TriggerType string `json:"trigger_type"`
ScheduledFor time.Time `json:"scheduled_for"`
Status string `json:"status"`
Attempts int `json:"attempts"`
WorkerID string `json:"worker_id"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Response json.RawMessage `json:"response,omitempty"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
}
type Service struct {
pool *pgxpool.Pool
cipher cryptox.Cipher
now func() time.Time
}
func NewService(pool *pgxpool.Pool, cipher cryptox.Cipher) *Service {
return &Service{pool: pool, cipher: cipher, now: time.Now}
}
const taskSelect = `SELECT id::text,code,name,description,cron_expression,timezone,target_type,target_code,prompt,variables,skill_ids::text[],mcp_server_ids::text[],conversation_id,notification_channel_id::text,encrypted_api_key,api_key_kek_version,enabled,next_run_at,last_run_at,last_status,last_error,created_by::text,revision,created_at,updated_at FROM gateway.scheduled_tasks`
func scanTask(row pgx.Row) (Task, error) {
var task Task
err := row.Scan(&task.ID, &task.Code, &task.Name, &task.Description, &task.CronExpression, &task.Timezone, &task.TargetType, &task.TargetCode, &task.Prompt, &task.Variables, &task.SkillIDs, &task.MCPServerIDs, &task.ConversationID, &task.NotificationChannelID, &task.encryptedAPIKey, &task.apiKeyKEKVersion, &task.Enabled, &task.NextRunAt, &task.LastRunAt, &task.LastStatus, &task.LastError, &task.CreatedBy, &task.Revision, &task.CreatedAt, &task.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Task{}, ErrNotFound
}
task.HasAPIKey = len(task.encryptedAPIKey) > 0
if task.SkillIDs == nil {
task.SkillIDs = []string{}
}
if task.MCPServerIDs == nil {
task.MCPServerIDs = []string{}
}
return task, err
}
func (s *Service) List(ctx context.Context) ([]Task, error) {
rows, err := s.pool.Query(ctx, taskSelect+` ORDER BY updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
item, scanErr := scanTask(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) Get(ctx context.Context, id string) (Task, error) {
return scanTask(s.pool.QueryRow(ctx, taskSelect+` WHERE id=$1`, id))
}
func normalizeIDs(values []string, maximum int) ([]string, error) {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
if !uuidPattern.MatchString(value) {
return nil, errors.New("资源 ID 格式无效")
}
seen[value] = true
result = append(result, value)
}
if len(result) > maximum {
return nil, fmt.Errorf("资源绑定最多允许 %d 项", maximum)
}
return result, nil
}
func (s *Service) validate(ctx context.Context, input *TaskInput, current *Task) (time.Time, []byte, int, error) {
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
input.CronExpression = strings.TrimSpace(input.CronExpression)
input.Timezone = strings.TrimSpace(input.Timezone)
input.TargetType = strings.TrimSpace(input.TargetType)
input.TargetCode = strings.ToLower(strings.TrimSpace(input.TargetCode))
input.Prompt = strings.TrimSpace(input.Prompt)
input.ConversationID = strings.TrimSpace(input.ConversationID)
if !codePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 {
return time.Time{}, nil, 0, errors.New("任务编码、名称或描述格式无效")
}
if len(input.Prompt) < 1 || len(input.Prompt) > 100000 || len(input.ConversationID) > 128 {
return time.Time{}, nil, 0, errors.New("提示词或会话 ID 格式无效")
}
if input.Timezone == "" {
input.Timezone = "UTC"
}
location, err := time.LoadLocation(input.Timezone)
if err != nil {
return time.Time{}, nil, 0, errors.New("时区名称无效")
}
schedule, err := ParseCron(input.CronExpression)
if err != nil {
return time.Time{}, nil, 0, err
}
next, err := schedule.Next(s.now(), location)
if err != nil {
return time.Time{}, nil, 0, err
}
if len(input.Variables) == 0 {
input.Variables = json.RawMessage(`{}`)
}
var variables map[string]any
if json.Unmarshal(input.Variables, &variables) != nil {
return time.Time{}, nil, 0, errors.New("变量必须是 JSON 对象")
}
input.Variables, _ = json.Marshal(variables)
if input.SkillIDs, err = normalizeIDs(input.SkillIDs, 100); err != nil {
return time.Time{}, nil, 0, err
}
if input.MCPServerIDs, err = normalizeIDs(input.MCPServerIDs, 100); err != nil {
return time.Time{}, nil, 0, err
}
if err = s.validateTarget(ctx, input); err != nil {
return time.Time{}, nil, 0, err
}
if input.NotificationChannelID != nil {
trimmed := strings.TrimSpace(*input.NotificationChannelID)
if trimmed == "" {
input.NotificationChannelID = nil
} else {
var exists bool
if err = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.notification_channels WHERE id=$1 AND enabled)`, trimmed).Scan(&exists); err != nil || !exists {
return time.Time{}, nil, 0, errors.New("通知渠道不存在或未启用")
}
input.NotificationChannelID = &trimmed
}
}
secret := strings.TrimSpace(input.APIKey)
if secret == "" && current == nil {
return time.Time{}, nil, 0, errors.New("首次创建必须填写执行 API Key")
}
if secret == "" {
return next, current.encryptedAPIKey, current.apiKeyKEKVersion, nil
}
if len(secret) > 512 {
return time.Time{}, nil, 0, errors.New("执行 API Key 过长")
}
encrypted, version, err := s.cipher.Encrypt([]byte(secret))
if err != nil {
return time.Time{}, nil, 0, fmt.Errorf("加密执行 API Key: %w", err)
}
return next, encrypted, version, nil
}
func subset(selected, allowed []string) bool {
set := map[string]bool{}
for _, id := range allowed {
set[id] = true
}
for _, id := range selected {
if !set[id] {
return false
}
}
return true
}
func (s *Service) validateTarget(ctx context.Context, input *TaskInput) error {
switch input.TargetType {
case "application":
if len(input.SkillIDs) > 0 || len(input.MCPServerIDs) > 0 {
return errors.New("应用任务不支持额外绑定 Skill 或 MCP")
}
var exists bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.applications WHERE code=$1 AND status='active' AND published_version IS NOT NULL)`, input.TargetCode).Scan(&exists)
if err != nil || !exists {
return errors.New("目标应用不存在或未发布")
}
var departmentIDs []string
_ = s.pool.QueryRow(ctx, `SELECT COALESCE(department_ids,'{}'::text[]) FROM gateway.applications WHERE code=$1`, input.TargetCode).Scan(&departmentIDs)
if len(departmentIDs) > 0 {
if err := s.requireKeyTenant(ctx, input.APIKey, departmentIDs); err != nil {
return err
}
}
case "digital_employee":
var skillIDs, mcpIDs, departmentIDs []string
var enabled bool
var status string
err := s.pool.QueryRow(ctx, `SELECT skill_ids::text[],mcp_server_ids::text[],enabled,status FROM gateway.digital_employees WHERE code=$1`, input.TargetCode).Scan(&skillIDs, &mcpIDs, &enabled, &status)
if err != nil || !enabled || status != "published" {
return errors.New("目标数字员工不存在或未发布")
}
if !subset(input.SkillIDs, skillIDs) || !subset(input.MCPServerIDs, mcpIDs) {
return errors.New("任务选择的 Skill/MCP 必须已绑定到目标数字员工")
}
_ = s.pool.QueryRow(ctx, `SELECT COALESCE(department_ids,'{}'::text[]) FROM gateway.digital_employees WHERE code=$1`, input.TargetCode).Scan(&departmentIDs)
if len(departmentIDs) > 0 {
if err := s.requireKeyTenant(ctx, input.APIKey, departmentIDs); err != nil {
return err
}
}
default:
return errors.New("目标类型必须是 application 或 digital_employee")
}
return nil
}
// requireKeyTenant 校验任务 API Key 的部门归属能访问部门限定目标,在保存
// 阶段就失败,而不是让任务创建成功后永远执行失败(执行 key 无 tenant 时
// 运行时对部门限定资源一律不可见)。secret 为空(更新时沿用旧 key)跳过。
func (s *Service) requireKeyTenant(ctx context.Context, secret string, departmentIDs []string) error {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil
}
hash, _ := apikey.Digest(secret)
var tenant *string
err := s.pool.QueryRow(ctx, `SELECT tenant_id::text FROM gateway.api_keys WHERE key_hash=$1 AND enabled`, hash).Scan(&tenant)
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("执行 API Key 不存在或已停用")
}
if err != nil {
return err
}
if tenant == nil {
return errors.New("目标资源按部门限定,但执行 API Key 未绑定部门;请使用该部门下的 API Key")
}
for _, id := range departmentIDs {
if id == *tenant {
return nil
}
}
return errors.New("执行 API Key 所属部门与目标资源部门不匹配")
}
func (s *Service) Save(ctx context.Context, id string, input TaskInput, actorID string) (Task, error) {
var current *Task
if id != "" {
item, err := s.Get(ctx, id)
if err != nil {
return Task{}, err
}
current = &item
}
next, encrypted, version, err := s.validate(ctx, &input, current)
if err != nil {
return Task{}, err
}
if id == "" {
id, err = platformid.NewUUID()
if err != nil {
return Task{}, err
}
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.scheduled_tasks(id,code,name,description,cron_expression,timezone,target_type,target_code,prompt,variables,skill_ids,mcp_server_ids,conversation_id,notification_channel_id,encrypted_api_key,api_key_kek_version,enabled,next_run_at,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, id, input.Code, input.Name, input.Description, input.CronExpression, input.Timezone, input.TargetType, input.TargetCode, input.Prompt, input.Variables, input.SkillIDs, input.MCPServerIDs, input.ConversationID, input.NotificationChannelID, encrypted, version, input.Enabled, nullableNext(input.Enabled, next), actorID)
} else {
_, err = s.pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET code=$2,name=$3,description=$4,cron_expression=$5,timezone=$6,target_type=$7,target_code=$8,prompt=$9,variables=$10,skill_ids=$11,mcp_server_ids=$12,conversation_id=$13,notification_channel_id=$14,encrypted_api_key=$15,api_key_kek_version=$16,enabled=$17,next_run_at=$18,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.CronExpression, input.Timezone, input.TargetType, input.TargetCode, input.Prompt, input.Variables, input.SkillIDs, input.MCPServerIDs, input.ConversationID, input.NotificationChannelID, encrypted, version, input.Enabled, nullableNext(input.Enabled, next))
}
if err != nil {
return Task{}, err
}
return s.Get(ctx, id)
}
func nullableNext(enabled bool, next time.Time) any {
if !enabled {
return nil
}
return next
}
func (s *Service) SetEnabled(ctx context.Context, id string, enabled bool) (Task, error) {
task, err := s.Get(ctx, id)
if err != nil {
return Task{}, err
}
var next any
if enabled {
location, _ := time.LoadLocation(task.Timezone)
schedule, _ := ParseCron(task.CronExpression)
value, nextErr := schedule.Next(s.now(), location)
if nextErr != nil {
return Task{}, nextErr
}
next = value
}
tag, err := s.pool.Exec(ctx, `UPDATE gateway.scheduled_tasks SET enabled=$2,next_run_at=$3,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, enabled, next)
if err != nil || tag.RowsAffected() == 0 {
return Task{}, ErrNotFound
}
return s.Get(ctx, id)
}
func (s *Service) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.scheduled_tasks WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) QueueManual(ctx context.Context, id string) (Run, error) {
if _, err := s.Get(ctx, id); err != nil {
return Run{}, err
}
runID, err := platformid.NewUUID()
if err != nil {
return Run{}, err
}
now := s.now().UTC()
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.scheduled_task_runs(id,task_id,trigger_type,scheduled_for) VALUES($1,$2,'manual',$3)`, runID, id, now)
if err != nil {
return Run{}, err
}
return s.getRun(ctx, runID)
}
const runSelect = `SELECT r.id::text,r.task_id::text,t.code,r.trigger_type,r.scheduled_for,r.status,r.attempts,r.worker_id,r.started_at,r.finished_at,r.response,r.error,r.created_at FROM gateway.scheduled_task_runs r JOIN gateway.scheduled_tasks t ON t.id=r.task_id`
func scanRun(row pgx.Row) (Run, error) {
var run Run
err := row.Scan(&run.ID, &run.TaskID, &run.TaskCode, &run.TriggerType, &run.ScheduledFor, &run.Status, &run.Attempts, &run.WorkerID, &run.StartedAt, &run.FinishedAt, &run.Response, &run.Error, &run.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Run{}, ErrNotFound
}
return run, err
}
func (s *Service) getRun(ctx context.Context, id string) (Run, error) {
return scanRun(s.pool.QueryRow(ctx, runSelect+` WHERE r.id=$1`, id))
}
func (s *Service) Runs(ctx context.Context, taskID string, limit int) ([]Run, error) {
if limit < 1 || limit > 500 {
limit = 100
}
rows, err := s.pool.Query(ctx, runSelect+` WHERE r.task_id=$1 ORDER BY r.created_at DESC LIMIT $2`, taskID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Run{}
for rows.Next() {
item, scanErr := scanRun(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) decryptAPIKey(task Task) (string, error) {
plain, err := s.cipher.Decrypt(task.encryptedAPIKey, task.apiKeyKEKVersion)
if err != nil {
return "", err
}
return string(plain), nil
}