Skip to content

Commit 79eaf8e

Browse files
aksOpsclaude
andcommitted
feat(serve): wire attention engine + webhook dispatcher into daemon
Integration commit that activates the two preceding packages. internal/serve/server.go: - New Options fields WebhookURL / WebhookAuth / AttentionThresholds (replacing the HasWebhook placeholder — derived now from WebhookURL != \"\", which also updates /api/bootstrap). - Lift the per-server CheckpointsCache onto the Server struct so both the REST handler and the attention engine's trigger-G lookup read through the same cache, keeping \"git log\" calls bounded. - Construct attention.Engine + webhook.Dispatcher in New(); both run as long-lived goroutines off Run(ctx) alongside the existing projection, quota, and tailer loops, with symmetric shutdown drains in both the ctx.Done and serve-error branches. - quotaEnricher.Attention(name) now delegates to the engine's Snapshot so /api/sessions surfaces the live state/since/details the UI's halftone border + AttentionLabel already know how to render. - Two small adapters (sessionSourceAdapter, sessionResolverAdapter) keep attention + webhook decoupled from the serve package's session/projection/git internals. cmd/serve.go: - Load config.Config up-front and forward Serve.WebhookURL, Serve.WebhookAuth, Serve.StatuslineDumpDir, and the resolved attention thresholds into serve.Options. Config-load failure is non-fatal — the daemon still boots on built-in defaults with a WARN log. - attentionThresholdsFrom maps config.AttentionThresholds onto attention.Thresholds; the attention package stays ignorant of the config package (and vice-versa). Runtime effect for existing installs: - Without any config.json edit, the attention engine runs with the spec defaults (20%/20, 5min, 85%, 90%, 30min) and alerts appear in the UI on real signals. - The webhook dispatcher's Run returns immediately until the user sets serve.webhook_url in config.json — zero regression for users who haven't opted in. Verification: - go test ./... — 492 pass across 24 packages (was 472/22) - go test -race ./internal/serve/... — 118 pass, no races - pnpm -C ui test — 12/12 pass (UI untouched) - govulncheck — no vulnerabilities - Smoke: daemon boots, /healthz returns 200, tailer adoption + orphan-UUID workdir lookup still route correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a7af9f3 commit 79eaf8e

2 files changed

Lines changed: 202 additions & 25 deletions

File tree

cmd/serve.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package cmd
33
import (
44
"context"
55
"errors"
6+
"log/slog"
67
"os/signal"
78
"syscall"
89

910
"github.com/spf13/cobra"
1011

12+
"github.com/RandomCodeSpace/ctm/internal/config"
1113
"github.com/RandomCodeSpace/ctm/internal/serve"
14+
"github.com/RandomCodeSpace/ctm/internal/serve/attention"
1215
)
1316

1417
var servePort int
@@ -35,10 +38,28 @@ the port, it exits non-zero without disturbing the foreign listener.`,
3538
syscall.SIGINT, syscall.SIGTERM)
3639
defer stop()
3740

38-
srv, err := serve.New(serve.Options{
39-
Port: servePort,
40-
Version: Version,
41-
})
41+
// Load config for Serve sub-struct (webhook URL/auth + attention
42+
// thresholds). A failure here is non-fatal — fall back to zero
43+
// values so the daemon still starts with built-in defaults.
44+
cfg, cfgErr := config.Load(config.ConfigPath())
45+
if cfgErr != nil {
46+
slog.Warn("serve: config load failed, using defaults", "err", cfgErr)
47+
}
48+
49+
opts := serve.Options{
50+
Port: servePort,
51+
Version: Version,
52+
WebhookURL: cfg.Serve.WebhookURL,
53+
WebhookAuth: cfg.Serve.WebhookAuth,
54+
AttentionThresholds: attentionThresholdsFrom(cfg.Serve.Attention),
55+
}
56+
// Let the config override the dump dir when set; otherwise
57+
// serve.New falls back to /tmp/ctm-statusline.
58+
if cfg.Serve.StatuslineDumpDir != "" {
59+
opts.StatuslineDumpDir = cfg.Serve.StatuslineDumpDir
60+
}
61+
62+
srv, err := serve.New(opts)
4263
if err != nil {
4364
if errors.Is(err, serve.ErrAlreadyRunning) {
4465
// Another ctm serve owns the port — silent success.
@@ -50,3 +71,18 @@ the port, it exits non-zero without disturbing the foreign listener.`,
5071
return srv.Run(ctx)
5172
},
5273
}
74+
75+
// attentionThresholdsFrom maps the config-layer thresholds (with zero
76+
// fall-back to defaults via Resolved()) into the attention package's
77+
// own type, keeping cmd/ as the integration seam.
78+
func attentionThresholdsFrom(c config.AttentionThresholds) attention.Thresholds {
79+
r := c.Resolved()
80+
return attention.Thresholds{
81+
ErrorRatePct: r.ErrorRatePct,
82+
ErrorRateWindow: r.ErrorRateWindow,
83+
IdleMinutes: r.IdleMinutes,
84+
QuotaPct: r.QuotaPct,
85+
ContextPct: r.ContextPct,
86+
YoloUncheckedMinutes: r.YoloUncheckedMinutes,
87+
}
88+
}

internal/serve/server.go

Lines changed: 162 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import (
2121

2222
"github.com/RandomCodeSpace/ctm/internal/config"
2323
"github.com/RandomCodeSpace/ctm/internal/serve/api"
24+
"github.com/RandomCodeSpace/ctm/internal/serve/attention"
2425
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
2526
"github.com/RandomCodeSpace/ctm/internal/serve/events"
2627
"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
28+
"github.com/RandomCodeSpace/ctm/internal/serve/webhook"
2729
"github.com/RandomCodeSpace/ctm/internal/tmux"
2830
)
2931

@@ -72,9 +74,18 @@ type Options struct {
7274
// means /tmp/ctm-statusline (per design spec §4 default).
7375
StatuslineDumpDir string
7476

75-
// HasWebhook is surfaced in /api/bootstrap response. Wired up in a
76-
// later step; defaults false.
77-
HasWebhook bool
77+
// WebhookURL enables the webhook dispatcher. Empty → disabled.
78+
// HasWebhook in /api/bootstrap is derived from this.
79+
WebhookURL string
80+
81+
// WebhookAuth, if non-empty, is sent verbatim in the Authorization
82+
// header on each POST (e.g. "Bearer abc123").
83+
WebhookAuth string
84+
85+
// AttentionThresholds overrides the built-in defaults for the
86+
// attention engine's seven triggers. A zero-valued Thresholds falls
87+
// back to attention.Defaults().
88+
AttentionThresholds attention.Thresholds
7889
}
7990

8091
// Server is the ctm serve HTTP daemon.
@@ -91,12 +102,15 @@ type Server struct {
91102
requestCtx context.Context
92103
requestCancel context.CancelFunc
93104

94-
token string
95-
hub *events.Hub
96-
proj *ingest.Projection
97-
tailers *ingest.TailerManager
98-
quota *ingest.QuotaIngester
99-
logDir string
105+
token string
106+
hub *events.Hub
107+
proj *ingest.Projection
108+
tailers *ingest.TailerManager
109+
quota *ingest.QuotaIngester
110+
cpCache *api.CheckpointsCache
111+
attention *attention.Engine
112+
webhook *webhook.Dispatcher
113+
logDir string
100114
}
101115

102116
// New binds the listener, loads the bearer token, constructs the hub
@@ -149,6 +163,40 @@ func New(opts Options) (*Server, error) {
149163

150164
hub := events.NewHub(0)
151165
proj := ingest.New(sessionsPath, tmux.NewClient(tmuxConf))
166+
quota := ingest.NewQuotaIngester(dumpDir, proj, hub)
167+
cpCache := api.NewCheckpointsCache()
168+
169+
// Attention engine: subscribes to hub, evaluates the seven triggers,
170+
// re-publishes attention_raised/_cleared on transitions. Wired into
171+
// quotaEnricher.Attention so /api/sessions surfaces the current
172+
// alert. Runs for the full daemon lifetime.
173+
thr := opts.AttentionThresholds
174+
if thr == (attention.Thresholds{}) {
175+
thr = attention.Defaults()
176+
}
177+
attEngine := attention.NewEngine(
178+
hub,
179+
quota,
180+
sessionSourceAdapter{proj: proj, cpCache: cpCache},
181+
thr,
182+
nil,
183+
)
184+
185+
// Webhook dispatcher: POSTs attention_raised events to a user-
186+
// configured URL with 3× exponential retry and 60 s debounce per
187+
// (session, alert). URL empty → Run returns immediately without
188+
// subscribing (dispatcher disabled).
189+
disp := webhook.NewDispatcher(
190+
hub,
191+
sessionResolverAdapter{proj: proj},
192+
webhook.Config{
193+
URL: opts.WebhookURL,
194+
AuthHeader: opts.WebhookAuth,
195+
UIBaseURL: fmt.Sprintf("http://127.0.0.1:%d", opts.Port),
196+
},
197+
nil,
198+
)
199+
152200
reqCtx, reqCancel := context.WithCancel(context.Background())
153201
s := &Server{
154202
opts: opts,
@@ -160,7 +208,10 @@ func New(opts Options) (*Server, error) {
160208
hub: hub,
161209
proj: proj,
162210
tailers: ingest.NewTailerManager(logDir, hub),
163-
quota: ingest.NewQuotaIngester(dumpDir, proj, hub),
211+
quota: quota,
212+
cpCache: cpCache,
213+
attention: attEngine,
214+
webhook: disp,
164215
logDir: logDir,
165216
}
166217

@@ -201,6 +252,16 @@ func (s *Server) Run(ctx context.Context) error {
201252
quotaDone := make(chan error, 1)
202253
go func() { quotaDone <- s.quota.Run(quotaCtx) }()
203254

255+
attCtx, attCancel := context.WithCancel(ctx)
256+
defer attCancel()
257+
attDone := make(chan error, 1)
258+
go func() { attDone <- s.attention.Run(attCtx) }()
259+
260+
whCtx, whCancel := context.WithCancel(ctx)
261+
defer whCancel()
262+
whDone := make(chan error, 1)
263+
go func() { whDone <- s.webhook.Run(whCtx) }()
264+
204265
// Tailer adoption: scan the JSONL log directory and spawn a tailer
205266
// for every UUID we find a log file for. The log files are the
206267
// ground truth (claude writes them via the log-tool-use hook
@@ -285,6 +346,10 @@ func (s *Server) Run(ctx context.Context) error {
285346
<-projDone
286347
quotaCancel()
287348
<-quotaDone
349+
attCancel()
350+
<-attDone
351+
whCancel()
352+
<-whDone
288353
s.tailers.StopAll()
289354
if errors.Is(err, http.ErrServerClosed) {
290355
return nil
@@ -304,6 +369,10 @@ func (s *Server) Run(ctx context.Context) error {
304369
<-projDone
305370
quotaCancel()
306371
<-quotaDone
372+
attCancel()
373+
<-attDone
374+
whCancel()
375+
<-whDone
307376
s.tailers.StopAll()
308377
err := <-serveDone
309378
if shutErr != nil {
@@ -329,9 +398,9 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
329398

330399
// Authenticated JSON endpoints.
331400
mux.Handle("GET /health", authHF(api.Health(s.opts.Version, ServeVersionHeader, s.startedAt, hubStatsAdapter{s.hub})))
332-
mux.Handle("GET /api/bootstrap", authHF(api.Bootstrap(s.opts.Version, s.opts.Port, s.opts.HasWebhook)))
401+
mux.Handle("GET /api/bootstrap", authHF(api.Bootstrap(s.opts.Version, s.opts.Port, s.opts.WebhookURL != "")))
333402

334-
enricher := quotaEnricher{quota: s.quota}
403+
enricher := quotaEnricher{quota: s.quota, attention: s.attention}
335404
mux.Handle("GET /api/sessions", authHF(api.List(s.proj, enricher)))
336405
mux.Handle("GET /api/sessions/{name}", authHF(api.Get(s.proj, enricher)))
337406
// Quota REST fallback so global rate-limit bars render on first
@@ -349,18 +418,18 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
349418
// Shared 5 s checkpoint cache: the /checkpoints handler and the
350419
// revert SHA-allowlist check both read through the same cache,
351420
// preventing a rapid-revert client from spinning up unbounded
352-
// `git log` subprocesses.
353-
cpCache := api.NewCheckpointsCache()
421+
// `git log` subprocesses. Also consumed by the attention engine
422+
// (trigger G yolo_unchecked).
354423
allowedSHA := func(name, sha string) bool {
355424
wd, ok := resolveWorkdir(name)
356425
if !ok {
357426
return false
358427
}
359428
// Full SHA equality only; abbreviated SHAs are intentionally
360429
// rejected (see api.CheckpointsCache.IsCheckpoint comment).
361-
return cpCache.IsCheckpoint(wd, name, sha)
430+
return s.cpCache.IsCheckpoint(wd, name, sha)
362431
}
363-
mux.Handle("GET /api/sessions/{name}/checkpoints", authHF(api.Checkpoints(resolveWorkdir, cpCache)))
432+
mux.Handle("GET /api/sessions/{name}/checkpoints", authHF(api.Checkpoints(resolveWorkdir, s.cpCache)))
364433
mux.Handle("POST /api/sessions/{name}/revert", authHF(api.Revert(resolveWorkdir, allowedSHA)))
365434
// Feed REST seed — parallel to /api/quota. Global ring and per-
366435
// session variant; both emit tool_call payloads only. See
@@ -399,11 +468,13 @@ type hubStatsAdapter struct{ hub *events.Hub }
399468
func (a hubStatsAdapter) Stats() any { return a.hub.Stats() }
400469

401470
// quotaEnricher adapts the QuotaIngester (in `internal/serve/ingest`)
402-
// to the api.SessionEnricher interface so per-session `context_pct`
403-
// shows up on /api/sessions responses without coupling the ingest
404-
// package to the api package.
471+
// and the attention engine to the api.SessionEnricher interface so
472+
// per-session `context_pct`, tokens, and current attention alert show
473+
// up on /api/sessions responses without coupling the api package to
474+
// ingest or attention internals.
405475
type quotaEnricher struct {
406-
quota *ingest.QuotaIngester
476+
quota *ingest.QuotaIngester
477+
attention *attention.Engine
407478
}
408479

409480
func (e quotaEnricher) ContextPct(name string) (int, bool) {
@@ -418,7 +489,18 @@ func (e quotaEnricher) LastToolCallAt(name string) (time.Time, bool) {
418489
}
419490

420491
func (e quotaEnricher) Attention(name string) (api.Attention, bool) {
421-
return api.Attention{}, false
492+
if e.attention == nil {
493+
return api.Attention{}, false
494+
}
495+
snap, ok := e.attention.Snapshot(name)
496+
if !ok {
497+
return api.Attention{}, false
498+
}
499+
return api.Attention{
500+
State: snap.State,
501+
Since: snap.Since,
502+
Details: snap.Details,
503+
}, true
422504
}
423505

424506
func (e quotaEnricher) Tokens(name string) (api.TokenUsage, bool) {
@@ -436,6 +518,65 @@ func (e quotaEnricher) Tokens(name string) (api.TokenUsage, bool) {
436518
}, true
437519
}
438520

521+
// sessionSourceAdapter satisfies attention.SessionSource by reading
522+
// through the live projection and the checkpoint cache. Projection
523+
// already owns its own tmux client for TmuxAlive; checkpoint freshness
524+
// (for trigger G) comes from a bounded-limit cache Get that avoids
525+
// unbounded `git log` calls per tick.
526+
type sessionSourceAdapter struct {
527+
proj *ingest.Projection
528+
cpCache *api.CheckpointsCache
529+
}
530+
531+
func (a sessionSourceAdapter) Names() []string {
532+
all := a.proj.All()
533+
out := make([]string, 0, len(all))
534+
for _, s := range all {
535+
out = append(out, s.Name)
536+
}
537+
return out
538+
}
539+
540+
func (a sessionSourceAdapter) Mode(name string) string {
541+
s, ok := a.proj.Get(name)
542+
if !ok {
543+
return ""
544+
}
545+
return s.Mode
546+
}
547+
548+
func (a sessionSourceAdapter) TmuxAlive(name string) bool {
549+
return a.proj.TmuxAlive(name)
550+
}
551+
552+
func (a sessionSourceAdapter) LastCheckpointAt(name string) (time.Time, bool) {
553+
s, ok := a.proj.Get(name)
554+
if !ok || s.Workdir == "" {
555+
return time.Time{}, false
556+
}
557+
cps, err := a.cpCache.Get(s.Workdir, name, 1)
558+
if err != nil || len(cps) == 0 {
559+
return time.Time{}, false
560+
}
561+
t, perr := time.Parse(time.RFC3339, cps[0].TS)
562+
if perr != nil {
563+
return time.Time{}, false
564+
}
565+
return t, true
566+
}
567+
568+
// sessionResolverAdapter satisfies webhook.SessionResolver so webhook
569+
// payloads carry session_uuid / workdir / mode alongside the alert.
570+
type sessionResolverAdapter struct{ proj *ingest.Projection }
571+
572+
func (a sessionResolverAdapter) Resolve(name string) (uuid, workdir, mode string, ok bool) {
573+
s, found := a.proj.Get(name)
574+
if !found {
575+
return "", "", "", false
576+
}
577+
return s.UUID, s.Workdir, s.Mode, true
578+
}
579+
439580
// quotaSourceAdapter wraps the ingester's GlobalSnapshot return into
440581
// the api-package's private quotaSnapshot, keeping the ingest →
441582
// api dependency one-way (api stays ignorant of ingest internals).

0 commit comments

Comments
 (0)