Files
Cyrene_v2/internal/services/automations/automations.go
T
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00

203 lines
8.0 KiB
Go

// Package automations implements the Cyrene v2 automation engine and reminder
// scheduler (DevDocs/design/04-运行时.md §8.5). It is the "how she does things on
// her own" layer: schedules and event rules fire a named action, the action runs
// (either a plain function, a registered tool, or a prompt through the AI
// loop/agent pipeline), and every outcome is published back to the bus so upper
// layers can react or push notifications.
//
// The package is split into five files:
//
// - automations.go — shared types: Config, Deps, the Pipeline abstraction, the
// published Result and the sentinel errors.
// - scheduler.go — the cron-driven Scheduler (robfig/cron/v3).
// - rule.go — the Rule model and the event-driven Engine.
// - action.go — the Action interface plus func/tool/AI implementations.
// - reminder.go — the time-based ReminderStore.
// - service.go — the Service that composes the three for lifecycle control.
//
// Execution model: every trigger (cron fire, event match, reminder due) resolves
// to the same runAction, which executes the Action under an optional deadline and
// publishes a Result to the "automation.result" subject. This keeps reporting and
// logging consistent across all trigger kinds (DevDocs/design/04-运行时.md §8.5:
// task output returns to the message queue/agent).
//
// Identity and capability boundaries (Devedocs global red lines): automation
// never decides who is allowed to do anything. It only fires named actions;
// authorization stays in the channel/policy layer.
package automations
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Bus subjects published by the automations engine. Upper layers subscribe to
// these to observe outcomes or push notifications.
const (
// SubjectResult is the subject every executed automation publishes its
// Result to.
SubjectResult = "automation.result"
// SubjectTrigger is the subject a due Reminder is published to. Upper layers
// subscribe to it to surface the reminder to the user.
SubjectTrigger = "reminder.trigger"
)
// Sentinel errors returned by this package. Use errors.Is to test for them.
var (
// ErrNilBus is returned when a constructor receives a nil bus.
ErrNilBus = errors.New("automations: nil bus")
// ErrNilAction is returned when an action is nil or unnamed.
ErrNilAction = errors.New("automations: nil action")
// ErrInvalidRule is returned when a rule is malformed.
ErrInvalidRule = errors.New("automations: invalid rule")
// ErrInvalidCron is returned when a cron expression fails to parse.
ErrInvalidCron = errors.New("automations: invalid cron expression")
// ErrNotFound is returned when a rule or reminder is missing.
ErrNotFound = errors.New("automations: not found")
// ErrDuplicate is returned when a rule or reminder id is already registered.
ErrDuplicate = errors.New("automations: already exists")
// ErrNoPipeline is returned when an AI action is built without a pipeline.
ErrNoPipeline = errors.New("automations: no AI pipeline configured")
)
// Config configures the automations engine.
type Config struct {
// Location is the timezone used for cron schedules and reminder firing. Nil
// uses time.Local.
Location *time.Location
// WithSeconds enables 6-field cron specs (with a leading seconds field) in
// addition to the standard 5-field format.
WithSeconds bool
// ActionTimeout bounds a single action execution. Non-positive disables the
// deadline.
ActionTimeout time.Duration
// ReminderInterval is how often the reminder loop polls for due reminders.
// Non-positive selects defaultReminderInterval.
ReminderInterval time.Duration
}
// Deps is the injected dependency set for the automations engine.
type Deps struct {
// Bus is the in-process event bus. Required.
Bus bus.Bus
// Loop is the AI pipeline (the agent loop). When set, an AI action runs
// through it. Agent is used only when Loop is nil.
Loop *loop.Loop
// Agent is the single-step agent, used as the AI pipeline when Loop is nil.
Agent *agent.Agent
// Log is the base logger. A nil logger is replaced with a discard logger
// tagged with source "services/automations".
Log *slog.Logger
// Now supplies the current time for reminders. Nil uses time.Now.
Now func() time.Time
}
// Pipeline is the AI-execution surface an automation action runs through. It is
// satisfied by *loop.Loop directly, so a real pipeline can be injected without
// an adapter. Tests inject a fake to observe the prompt and return canned text.
type Pipeline interface {
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// AgentPipeline adapts an *agent.Agent to Pipeline by running a single step and
// returning its text as the result. It is used when only an agent (not a full
// loop) is injected.
type AgentPipeline struct {
Agent *agent.Agent
}
// Run runs one agent step and returns its text as the loop-style Result.
func (a *AgentPipeline) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
if a.Agent == nil {
return nil, fmt.Errorf("automations: agent pipeline: %w", ErrNoPipeline)
}
sr, err := a.Agent.RunStep(ctx, sessionID, messages)
if err != nil {
return nil, fmt.Errorf("automations: agent pipeline run: %w", err)
}
return &loop.Result{Text: sr.Text, StopReason: sr.StopReason, Steps: 1}, nil
}
// pipeline returns the resolved AI pipeline from deps, or nil when neither a
// loop nor an agent is configured.
func (d Deps) pipeline() Pipeline {
if d.Loop != nil {
return d.Loop
}
if d.Agent != nil {
return &AgentPipeline{Agent: d.Agent}
}
return nil
}
// AutomationRecord is the read-only summary of one automation or reminder,
// returned by Service.List for the admin panel. Kind is "cron", "event" or
// "reminder"; Cron carries the cron expression for the cron kind; LastRun and
// NextRun are zero times when the value is unknown (typically before Start).
type AutomationRecord struct {
ID string
Name string
Kind string
Cron string
Enabled bool
LastRun time.Time
NextRun time.Time
}
// Result is the structured outcome of an executed automation, published to
// SubjectResult. Upper layers subscribe to it to react or push notifications.
type Result struct {
// Kind classifies the trigger: "schedule", "event" or "reminder".
Kind string `json:"kind"`
// RuleID identifies the schedule/rule/reminder that fired.
RuleID string `json:"rule_id"`
// Action is the action name that ran.
Action string `json:"action"`
// Payload is the trigger payload (nil for a cron fire).
Payload any `json:"payload"`
// Output is the action's returned value, or nil on error.
Output any `json:"output"`
// Error carries the action's error message when the run failed.
Error string `json:"error,omitempty"`
}
// sourcedLog returns a base logger tagged with the module source
// "services/automations", replacing a nil logger with a discard one so callers
// can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "services/automations")
}
// resolveNow returns f when non-nil, otherwise time.Now.
func resolveNow(f func() time.Time) func() time.Time {
if f == nil {
return time.Now
}
return f
}
// payloadKind 返回触发 payload 的 Go 类型名,用于日志里替代 payload 本身。
// 类型名是代码事实(*gateway.ChatEvent 之类),正文才是敏感物 —— 排查「这条路
// 上跑的是什么消息」靠类型名与体积够了,不需要内容。
//
// 它**不**调用 payload 的 String()/Error():那正是把正文请进日志的常见口子。
func payloadKind(payload any) string {
if payload == nil {
return "nil"
}
return fmt.Sprintf("%T", payload)
}