Repository navigation
Expand file tree
/
Copy pathengine_lifecycle.go
More file actions
189 lines (174 loc) · 6.78 KB
/
Copy pathengine_lifecycle.go
File metadata and controls
189 lines (174 loc) · 6.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
package main
import (
"context"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/renezander030/draftcat/internal/config"
"github.com/renezander030/draftcat/internal/obs"
"github.com/renezander030/draftcat/internal/redact"
statestore "github.com/renezander030/draftcat/internal/state"
)
const defaultShutdownGrace = 30 * time.Second
// secretEnvNames lists every environment variable the engine reads a
// credential from, labelled by the name the operator configured.
func secretEnvNames(cfg *config.Config) []string {
names := []string{
firstNonEmpty(cfg.Telegram.TokenEnv, "DRAFTCAT_TG_TOKEN"),
firstNonEmpty(cfg.Relay.SecretEnv, "DRAFTCAT_RELAY_SECRET"),
firstNonEmpty(cfg.Provider.APIKeyEnv, "OPENROUTER_API_KEY"),
"DRAFTCAT_APPROVAL_SECRET",
}
for _, n := range []string{cfg.Webhook.SecretEnv, cfg.GHL.APIKeyEnv, cfg.Observ.OTLP.HeaderEnv} {
if n != "" {
names = append(names, n)
}
}
return names
}
// registerSecrets registers the value of every credential variable for
// redaction. OTLP header variables hold "k=v,k2=v2"; each value is
// registered on its own as well as the whole string.
func registerSecrets(cfg *config.Config) {
for _, name := range secretEnvNames(cfg) {
v := os.Getenv(name)
redact.Register(name, v)
if name == cfg.Observ.OTLP.HeaderEnv {
for _, hv := range obs.ParseHeaderEnv(v) {
redact.Register(name, hv)
// "Bearer <token>" — the token alone may also be echoed.
if i := strings.IndexByte(hv, ' '); i > 0 {
redact.Register(name, hv[i+1:])
}
}
}
}
// Secrets already resolved into the config, in case they came from a
// fallback variable.
redact.Register(firstNonEmpty(cfg.Telegram.TokenEnv, "DRAFTCAT_TG_TOKEN"), cfg.Telegram.Token())
redact.Register(firstNonEmpty(cfg.Relay.SecretEnv, "DRAFTCAT_RELAY_SECRET"), cfg.Relay.Secret())
redact.Register(firstNonEmpty(cfg.Provider.APIKeyEnv, "OPENROUTER_API_KEY"), cfg.Provider.APIKey())
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// restoreSchedules continues every pipeline's schedule and failure streak
// from the run history, and tells the operator about pipelines that start
// paused because their recorded streak reached pause_after_failures.
func restoreSchedules(sched *Scheduler, cfg *config.Config, store *statestore.StateStore, ch interface{ Send(string) error }) {
if store == nil {
return
}
for _, p := range cfg.Pipelines {
n := p.PauseAfterFailures
if n < 1 {
n = 1
}
runs, err := store.RecentRuns(p.Name, n)
if err != nil {
log.Printf("[scheduler] run history for %s unavailable: %v", p.Name, err)
continue
}
if sched.Restore(p.Name, runs) {
log.Printf("[scheduler] %s starts paused: last %d runs failed", p.Name, len(runs))
if ch != nil {
msg := autoPauseNotice(p.Name, len(runs), nil)
if len(runs) > 0 && runs[0].Error != "" {
msg = autoPauseNotice(p.Name, len(runs), errText(runs[0].Error))
}
_ = ch.Send(msg)
}
}
}
}
type errText string
func (e errText) Error() string { return string(e) }
// shutdownGrace returns timeouts.shutdown_grace, default 30s.
func shutdownGrace(cfg *config.Config) time.Duration {
if v := strings.TrimSpace(cfg.Timeouts.ShutdownGrace); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return defaultShutdownGrace
}
// drainEngine stops admitting runs, closes the webhook listener after its
// in-flight requests, and waits up to grace for admitted pipeline runs.
// Approval taps keep arriving while it waits, so a run blocked on a decision
// can still finish. An approval gate still open at exit is reconciled at the
// next start.
func drainEngine(sched *Scheduler, webhook *http.Server, grace time.Duration) bool {
sched.BeginDrain()
if webhook != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := webhook.Shutdown(ctx); err != nil {
log.Printf("[shutdown] webhook listener: %v", err)
}
cancel()
}
running := sched.RunningNames()
if len(running) == 0 {
return true
}
log.Printf("[shutdown] waiting up to %s for %d running pipeline(s): %s", grace, len(running), strings.Join(running, ", "))
if sched.Wait(grace) {
log.Printf("[shutdown] all pipeline runs finished")
return true
}
log.Printf("[shutdown] grace period elapsed with %s still running; open approval gates are reconciled at next start", strings.Join(sched.RunningNames(), ", "))
return false
}
// redactedError keeps the wrapped error for errors.Is/As while its message
// carries no registered secret.
type redactedError struct{ err error }
func (e redactedError) Error() string { return redact.String(e.err.Error()) }
func (e redactedError) Unwrap() error { return e.err }
// redactErr returns err with registered secrets removed from its message.
func redactErr(err error) error {
if err == nil {
return nil
}
return redactedError{err}
}
// engineGauges reports current engine state for /metrics: runs in progress,
// paused pipelines, open approval gates and today's usage against the caps.
func engineGauges(sched *Scheduler, budget *BudgetTracker, cfg *config.Config, store *statestore.StateStore) []obs.Gauge {
var out []obs.Gauge
running, _ := sched.Counts()
out = append(out, obs.Gauge{Name: "draftcat_pipelines_running", Help: "Pipeline runs in progress.", Value: float64(running)})
for _, ps := range sched.GetAll() {
v := 0.0
if ps.Paused {
v = 1
}
out = append(out, obs.Gauge{Name: "draftcat_pipeline_paused", Help: "1 when the pipeline's timer is paused.", Labels: []string{"pipeline", ps.Name}, Value: v})
out = append(out, obs.Gauge{Name: "draftcat_pipeline_consecutive_failures", Help: "Consecutive failed runs of the pipeline.", Labels: []string{"pipeline", ps.Name}, Value: float64(ps.Failures)})
}
if store != nil {
if open, err := store.OpenApprovals(); err == nil {
out = append(out, obs.Gauge{Name: "draftcat_approvals_open", Help: "Approval gates waiting on a decision.", Value: float64(len(open))})
}
}
if budget != nil {
snap := budget.snapshot()
out = append(out,
obs.Gauge{Name: "draftcat_budget_day_tokens", Help: "Tokens used today (UTC).", Value: float64(snap.tokensUsedToday)},
obs.Gauge{Name: "draftcat_budget_day_cost", Help: "Cost spent today (UTC), in the unit of the model rates.", Value: snap.costToday},
obs.Gauge{Name: "draftcat_budget_unsettled_calls", Help: "Provider calls whose usage is not settled yet.", Value: float64(snap.unsettled)},
)
if cfg.Budgets.PerDayTokens > 0 {
out = append(out, obs.Gauge{Name: "draftcat_budget_day_tokens_limit", Help: "Daily token cap.", Value: float64(cfg.Budgets.PerDayTokens)})
}
if cfg.Budgets.PerDayCost > 0 {
out = append(out, obs.Gauge{Name: "draftcat_budget_day_cost_limit", Help: "Daily cost cap.", Value: cfg.Budgets.PerDayCost})
}
}
return out
}