Skip to content

Commit 40d0b0c

Browse files
feat(status): expose postprocess policy summary
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 32fead4 commit 40d0b0c

2 files changed

Lines changed: 77 additions & 15 deletions

File tree

cmd/ccg/health_test.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
postprocesspolicy "github.com/tae2089/code-context-graph/internal/postprocess/policy"
1314
"github.com/tae2089/code-context-graph/internal/webhook"
1415
)
1516

@@ -110,7 +111,7 @@ func TestStatusHandler_ReportsWebhookQueueFull(t *testing.T) {
110111
req := httptest.NewRequest(http.MethodGet, "/status", nil)
111112
rec := httptest.NewRecorder()
112113

113-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
114+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
114115

115116
if rec.Code != http.StatusServiceUnavailable {
116117
t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
@@ -133,7 +134,7 @@ func TestStatusHandler_ReportsWebhookQueueAges(t *testing.T) {
133134
req := httptest.NewRequest(http.MethodGet, "/status", nil)
134135
rec := httptest.NewRecorder()
135136

136-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
137+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
137138

138139
if rec.Code != http.StatusOK {
139140
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
@@ -204,7 +205,7 @@ func TestStatusHandler_ReportsUnresolvedWebhookFailure(t *testing.T) {
204205
req := httptest.NewRequest(http.MethodGet, "/status", nil)
205206
rec := httptest.NewRecorder()
206207

207-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
208+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
208209

209210
if rec.Code != http.StatusOK {
210211
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
@@ -280,7 +281,7 @@ func TestStatusHandler_DegradedWhenRecentRepoFailureUnresolved(t *testing.T) {
280281

281282
req := httptest.NewRequest(http.MethodGet, "/status", nil)
282283
rec := httptest.NewRecorder()
283-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
284+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
284285

285286
if rec.Code != http.StatusOK {
286287
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
@@ -323,7 +324,7 @@ func TestStatusHandler_DBNotReadyTakesPrecedenceOverWebhookDegraded(t *testing.T
323324

324325
req := httptest.NewRequest(http.MethodGet, "/status", nil)
325326
rec := httptest.NewRecorder()
326-
statusHandler(func(r *http.Request) error { return errors.New("db down") }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
327+
statusHandler(func(r *http.Request) error { return errors.New("db down") }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
327328

328329
if rec.Code != http.StatusServiceUnavailable {
329330
t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
@@ -380,7 +381,7 @@ func TestStatusHandler_ReportsPerRepoRecentRepos(t *testing.T) {
380381

381382
req := httptest.NewRequest(http.MethodGet, "/status", nil)
382383
rec := httptest.NewRecorder()
383-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
384+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
384385

385386
if rec.Code != http.StatusOK {
386387
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
@@ -441,7 +442,7 @@ func TestStatusHandler_RecentRepos_FailureHasErrorFields(t *testing.T) {
441442

442443
req := httptest.NewRequest(http.MethodGet, "/status", nil)
443444
rec := httptest.NewRecorder()
444-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
445+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
445446

446447
var body map[string]any
447448
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
@@ -473,7 +474,7 @@ func TestStatusHandler_ExistingAggregateFieldsPreserved(t *testing.T) {
473474

474475
req := httptest.NewRequest(http.MethodGet, "/status", nil)
475476
rec := httptest.NewRecorder()
476-
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }).ServeHTTP(rec, req)
477+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, func() *webhook.SyncQueue { return q }, nil).ServeHTTP(rec, req)
477478

478479
var body map[string]any
479480
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
@@ -490,6 +491,41 @@ func TestStatusHandler_ExistingAggregateFieldsPreserved(t *testing.T) {
490491
}
491492
}
492493

494+
func TestStatusHandler_ReportsPostprocessSummary(t *testing.T) {
495+
req := httptest.NewRequest(http.MethodGet, "/status", nil)
496+
rec := httptest.NewRecorder()
497+
498+
statusHandler(func(r *http.Request) error { return nil }, time.Minute, nil, func(context.Context) (*postprocesspolicy.StatusSummary, error) {
499+
return &postprocesspolicy.StatusSummary{
500+
Status: postprocesspolicy.StatusDegraded,
501+
FailClosed: []postprocesspolicy.StateSnapshot{{
502+
Namespace: "default",
503+
Tool: postprocesspolicy.ToolRunPostprocess,
504+
Policy: postprocesspolicy.PolicyFailClosed,
505+
ConsecutiveFailures: 3,
506+
}},
507+
}, nil
508+
}).ServeHTTP(rec, req)
509+
510+
if rec.Code != http.StatusOK {
511+
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
512+
}
513+
var body map[string]any
514+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
515+
t.Fatalf("decode status: %v", err)
516+
}
517+
if got := body["status"]; got != "degraded" {
518+
t.Fatalf("status = %v, want degraded", got)
519+
}
520+
postprocessBody, ok := body["postprocess"].(map[string]any)
521+
if !ok {
522+
t.Fatalf("missing postprocess body: %v", body)
523+
}
524+
if got := postprocessBody["status"]; got != "degraded" {
525+
t.Fatalf("postprocess.status = %v, want degraded", got)
526+
}
527+
}
528+
493529
type recordingPool struct {
494530
maxOpen int
495531
maxIdle int

cmd/ccg/main.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -831,12 +831,18 @@ func runServe(deps *cli.Deps, cfg cli.ServeConfig) error {
831831
MaxFileBytes: cfg.MaxFileBytes,
832832
MaxTotalParsedBytes: cfg.MaxTotalParsedBytes,
833833
}
834+
postprocessSummary := func(ctx context.Context) (*postprocesspolicy.StatusSummary, error) {
835+
if mcpDeps.PostprocessPolicy == nil {
836+
return nil, nil
837+
}
838+
return mcpDeps.PostprocessPolicy.Status(ctx, postprocesspolicy.StatusOptions{RecentLimit: postprocesspolicy.DefaultStatusLimit})
839+
}
834840

835841
srv := mcpserver.NewServer(mcpDeps)
836842

837843
switch cfg.Transport {
838844
case "streamable-http":
839-
return serveStreamableHTTP(deps, srv, cfg)
845+
return serveStreamableHTTP(deps, srv, cfg, postprocessSummary)
840846
default:
841847
deps.Logger.Info("serving MCP over stdio")
842848
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -880,10 +886,18 @@ func (p *mcpPostprocessPolicy) RecordRun(ctx context.Context, record postprocess
880886
return p.store.RecordRun(ctx, record)
881887
}
882888

889+
func (p *mcpPostprocessPolicy) Status(ctx context.Context, opts postprocesspolicy.StatusOptions) (*postprocesspolicy.StatusSummary, error) {
890+
return p.store.Status(ctx, opts)
891+
}
892+
893+
func (p *mcpPostprocessPolicy) Reset(ctx context.Context, tool string) error {
894+
return p.store.Reset(ctx, tool)
895+
}
896+
883897
// serveStreamableHTTP serves the MCP server over streamable HTTP.
884898
// @intent 원격 MCP 클라이언트를 위한 HTTP 엔드포인트와 헬스체크를 노출한다.
885899
// @sideEffect HTTP 리스너를 열고 종료 시 graceful shutdown을 수행한다.
886-
func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeConfig) error {
900+
func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeConfig, postprocessSummary func(context.Context) (*postprocesspolicy.StatusSummary, error)) error {
887901
deps.Logger.Info("serving MCP over streamable-http", "addr", cfg.HTTPAddr, "stateless", cfg.Stateless)
888902

889903
if err := validateHTTPExposure(cfg); err != nil {
@@ -938,7 +952,7 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
938952
}))
939953
mux.Handle("/status", statusHandler(dbReadyCheck, cfg.WebhookAttemptTimeout, func() *webhook.SyncQueue {
940954
return syncQueue
941-
}))
955+
}, postprocessSummary))
942956

943957
if len(cfg.AllowRepo) > 0 {
944958
rules := make([]webhook.RepoRule, 0, len(cfg.AllowRepo))
@@ -1149,12 +1163,13 @@ func readyHandler(check func(*http.Request) error) http.Handler {
11491163
}
11501164

11511165
type statusResponse struct {
1152-
Status string `json:"status"`
1153-
DB string `json:"db"`
1154-
Webhook *webhook.SyncQueueStats `json:"webhook,omitempty"`
1166+
Status string `json:"status"`
1167+
DB string `json:"db"`
1168+
Webhook *webhook.SyncQueueStats `json:"webhook,omitempty"`
1169+
Postprocess *postprocesspolicy.StatusSummary `json:"postprocess,omitempty"`
11551170
}
11561171

1157-
func statusHandler(dbCheck func(*http.Request) error, webhookTimeout time.Duration, queue func() *webhook.SyncQueue) http.Handler {
1172+
func statusHandler(dbCheck func(*http.Request) error, webhookTimeout time.Duration, queue func() *webhook.SyncQueue, postprocessSummary func(context.Context) (*postprocesspolicy.StatusSummary, error)) http.Handler {
11581173
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11591174
if r.Method != http.MethodGet {
11601175
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -1180,6 +1195,17 @@ func statusHandler(dbCheck func(*http.Request) error, webhookTimeout time.Durati
11801195
}
11811196
}
11821197
}
1198+
if postprocessSummary != nil {
1199+
summary, err := postprocessSummary(r.Context())
1200+
if err == nil {
1201+
resp.Postprocess = summary
1202+
if code == http.StatusOK && summary != nil && summary.Status == postprocesspolicy.StatusDegraded {
1203+
resp.Status = "degraded"
1204+
}
1205+
} else {
1206+
slog.Error("postprocess status summary failed", "error", err)
1207+
}
1208+
}
11831209

11841210
w.Header().Set("Content-Type", "application/json")
11851211
w.WriteHeader(code)

0 commit comments

Comments
 (0)