Files
ARTEX/server/scheduler.go
T
Autumn-27andClaude Opus 4.8 0ef8561411 chore: ARTEX 初始化
ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 04:27:45 -04:00

248 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"time"
"github.com/Autumn-27/artex/db"
)
// Scheduler drives P3 triggers: on each tick it fires due interval triggers and
// scans for new findings / newly-met goals (any task) to fire event triggers.
// Each fire enqueues a NEW conversation on the agent's per-agent trigger queue
// (StartTriggeredRun): fires for the same agent run one at a time in FIFO order,
// distinct agents still run concurrently. State is persisted (per-trigger last_fire
// + 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
}
const (
schedKeyLastFinding = "last_finding_id" // watermark: max finding node id fired for
schedKeyFiredGoals = "fired_goals" // JSON array of goal node ids already fired
schedKeyLastTimeout = "last_timeout_id" // watermark: max task id fired for task timeout
)
func newScheduler(s *Server) *Scheduler {
return &Scheduler{s: s, pg: s.m.pg, tick: 5 * time.Second}
}
// Run loops until ctx is done, ticking the scheduler. Started once from server New.
func (sc *Scheduler) Run(ctx context.Context) {
if sc.pg == nil {
return
}
sc.init()
t := time.NewTicker(sc.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sc.step()
}
}
}
// init seeds the watermarks on first run so pre-existing findings/goals don't all
// fire at once — only events created AFTER the scheduler first starts count.
func (sc *Scheduler) init() {
if sc.mustState(schedKeyLastFinding) == "" {
var maxID int64
if evs, err := sc.pg.NewFindingsSince(0); err == nil {
for _, e := range evs {
if e.NodeID > maxID {
maxID = e.NodeID
}
}
}
_ = sc.pg.SetSchedState(schedKeyLastFinding, strconv.FormatInt(maxID, 10))
}
if sc.mustState(schedKeyFiredGoals) == "" {
set := map[int64]bool{}
if evs, err := sc.pg.MetGoals(); err == nil {
for _, e := range evs {
set[e.NodeID] = true
}
}
sc.saveFiredGoalSet(set)
}
if sc.mustState(schedKeyLastTimeout) == "" {
var maxID int64
if evs, err := sc.pg.TimedOutTasksSince(0); err == nil {
for _, e := range evs {
if e.NodeID > maxID {
maxID = e.NodeID
}
}
}
_ = sc.pg.SetSchedState(schedKeyLastTimeout, strconv.FormatInt(maxID, 10))
}
}
func (sc *Scheduler) step() {
triggers, err := sc.pg.ListEnabledTriggers()
if err != nil {
return
}
if len(triggers) == 0 {
return
}
sc.fireIntervals(triggers)
sc.fireFindings(triggers)
sc.fireGoals(triggers)
sc.fireTaskTimeouts(triggers)
}
// fireIntervals fires triggers whose interval has elapsed since last_fire.
func (sc *Scheduler) fireIntervals(triggers []*db.AgentTrigger) {
now := time.Now()
for _, tr := range triggers {
if tr.IntervalSec <= 0 {
continue
}
due := tr.LastFire == nil || now.Sub(*tr.LastFire) >= time.Duration(tr.IntervalSec)*time.Second
if !due {
continue
}
_ = sc.pg.TouchTriggerFire(tr.ID)
ctx := "\n\n【本次为定时触发】" + now.Format(" 2006-01-02 15:04:05 MST")
sc.s.StartTriggeredRun(tr.AgentKey, fmt.Sprintf("定时触发 · %s", now.Format("15:04")), tr.IntervalMessage+ctx, 0, false)
}
}
// fireFindings fires on_finding triggers for findings above the persisted
// watermark (monotonic node id → no double-fire across restarts).
func (sc *Scheduler) fireFindings(triggers []*db.AgentTrigger) {
var want []*db.AgentTrigger
for _, tr := range triggers {
if tr.OnFinding {
want = append(want, tr)
}
}
if len(want) == 0 {
return
}
last, _ := strconv.ParseInt(sc.mustState(schedKeyLastFinding), 10, 64)
events, err := sc.pg.NewFindingsSince(last)
if err != nil || len(events) == 0 {
return
}
maxID := last
for _, e := range events {
if e.NodeID > maxID {
maxID = e.NodeID
}
msgCtx := fmt.Sprintf("\n\n【本次由任务发现 finding 触发】\n任务: #%d %s(目标:%s)\n发现: [%s/%s] %s",
e.TaskID, e.TaskDesc, e.TaskGoal, e.VulnClass, e.Severity, e.Summary)
for _, tr := range want {
sc.s.StartTriggeredRun(tr.AgentKey, fmt.Sprintf("finding 触发 · task#%d", e.TaskID), tr.FindingMessage+msgCtx, e.TaskID, true)
}
}
_ = sc.pg.SetSchedState(schedKeyLastFinding, strconv.FormatInt(maxID, 10))
}
// fireGoals fires on_goal_met triggers for met goals not yet in the fired set.
func (sc *Scheduler) fireGoals(triggers []*db.AgentTrigger) {
var want []*db.AgentTrigger
for _, tr := range triggers {
if tr.OnGoalMet {
want = append(want, tr)
}
}
if len(want) == 0 {
return
}
events, err := sc.pg.MetGoals()
if err != nil || len(events) == 0 {
return
}
fired := sc.firedGoalSet()
changed := false
for _, e := range events {
if fired[e.NodeID] {
continue
}
fired[e.NodeID] = true
changed = true
msgCtx := fmt.Sprintf("\n\n【本次由任务完成目标触发】\n任务: #%d %s(目标:%s)\n达成目标: %s",
e.TaskID, e.TaskDesc, e.TaskGoal, e.Summary)
for _, tr := range want {
sc.s.StartTriggeredRun(tr.AgentKey, fmt.Sprintf("目标触发 · task#%d", e.TaskID), tr.GoalMessage+msgCtx, e.TaskID, true)
}
}
if changed {
sc.saveFiredGoalSet(fired)
}
}
// fireTaskTimeouts fires on_task_timeout triggers for tasks that newly reached
// status='timeout' above the persisted watermark (task id → no double-fire).
func (sc *Scheduler) fireTaskTimeouts(triggers []*db.AgentTrigger) {
var want []*db.AgentTrigger
for _, tr := range triggers {
if tr.OnTaskTimeout {
want = append(want, tr)
}
}
if len(want) == 0 {
return
}
last, _ := strconv.ParseInt(sc.mustState(schedKeyLastTimeout), 10, 64)
events, err := sc.pg.TimedOutTasksSince(last)
if err != nil || len(events) == 0 {
return
}
maxID := last
for _, e := range events {
if e.NodeID > maxID {
maxID = e.NodeID
}
msgCtx := fmt.Sprintf("\n\n【本次由任务超时触发】\n任务: #%d %s(目标:%s)",
e.TaskID, e.TaskDesc, e.TaskGoal)
for _, tr := range want {
sc.s.StartTriggeredRun(tr.AgentKey, fmt.Sprintf("超时触发 · task#%d", e.TaskID), tr.TaskTimeoutMessage+msgCtx, e.TaskID, true)
}
}
_ = sc.pg.SetSchedState(schedKeyLastTimeout, strconv.FormatInt(maxID, 10))
}
func (sc *Scheduler) mustState(key string) string {
v, _ := sc.pg.GetSchedState(key)
return v
}
func (sc *Scheduler) firedGoalSet() map[int64]bool {
out := map[int64]bool{}
raw := sc.mustState(schedKeyFiredGoals)
if raw == "" {
return out
}
var ids []int64
if json.Unmarshal([]byte(raw), &ids) == nil {
for _, id := range ids {
out[id] = true
}
}
return out
}
func (sc *Scheduler) saveFiredGoalSet(set map[int64]bool) {
ids := make([]int64, 0, len(set))
for id := range set {
ids = append(ids, id)
}
b, _ := json.Marshal(ids)
if err := sc.pg.SetSchedState(schedKeyFiredGoals, string(b)); err != nil {
log.Printf("[scheduler] save fired goals failed: %v", err)
}
}