Problem: the hexagonal-architecture plan (plans/2026-08-15-hexagonal-
architecture.md) needs its foundation — an accepted ADR, the target
directory tree, and machine-checked dependency rules — before any
service extraction starts. Also folds the four outstanding review
findings (F3.1/F5/F6/F7) into the plan: ObservationService owns the
bounded probe-concurrency contract (scheduler.go:133), Phase 9 gates
ExecutionService+PolicyService ≥ 90% with a gating-matrix test,
per-phase abort criteria, and the §3.2 internal/config note.
Change:
- docs/adr/0016-hexagonal-ports-adapters.md records context, decision,
and consequences of the ports & adapters migration.
- internal/domain → internal/core/domain (mechanical import rewrite,
20 files), new internal/core/{ports,app}, internal/adapters trees
with package docs.
- .golangci.yml: depguard rules for §3.1 (core purity, no agent-client
tech in core, nomos isolation — the nomos rules self-activate when
internal/nomos exists in Phase 8). Config migrated to golangci-lint
v2 format so it loads at all (the v1 config errored under v2, masked
by CI's advisory continue-on-error). Verified depguard fires on a
planted openai-go import in internal/core/app.
- CONTRIBUTING.md layout section now shows the core/adapters tree.
Risk: import path churn is mechanical and tests pass unchanged; the
lint config migration surfaces the pre-existing 400-issue baseline
(advisory in CI, unchanged policy) — new/moved packages lint clean.
Verification: go vet ./..., make test (race, core/domain at 100%
coverage), make generate-check, golangci-lint on internal/core/... and
internal/adapters/... — 0 issues; depguard violation probe confirmed.
181 lines
4.5 KiB
Go
181 lines
4.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// ─── Metrics ───────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
|
|
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
|
|
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
|
|
}
|
|
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
from := time.Now().Add(-24 * time.Hour)
|
|
if req.Params.From != nil {
|
|
from = *req.Params.From
|
|
}
|
|
to := time.Now()
|
|
if req.Params.To != nil {
|
|
to = *req.Params.To
|
|
}
|
|
|
|
var metricNames []string
|
|
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
|
|
metricNames = *req.Params.Metric
|
|
} else {
|
|
// metric omitted: report every metric recorded for this entity in range.
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT DISTINCT metric FROM metric_samples
|
|
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
|
|
ORDER BY metric`, entityID, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
items := []gen.MetricSeries{}
|
|
for _, metricName := range metricNames {
|
|
series := gen.MetricSeries{
|
|
EntityId: entityID.String(),
|
|
Metric: metricName,
|
|
Rollup: gen.MetricSeriesRollupRaw,
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT ts, value
|
|
FROM metric_samples
|
|
WHERE entity_id = $1 AND metric = $2
|
|
AND ts >= $3 AND ts <= $4
|
|
ORDER BY ts ASC`,
|
|
entityID, metricName, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
samples := []struct {
|
|
Avg *float32 `json:"avg"`
|
|
Count *int `json:"count"`
|
|
Max *float32 `json:"max"`
|
|
Min *float32 `json:"min"`
|
|
Ts time.Time `json:"ts"`
|
|
Value *float32 `json:"value"`
|
|
}{}
|
|
|
|
for rows.Next() {
|
|
var ts time.Time
|
|
var val float64
|
|
if err := rows.Scan(&ts, &val); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
f := float32(val)
|
|
samples = append(samples, struct {
|
|
Avg *float32 `json:"avg"`
|
|
Count *int `json:"count"`
|
|
Max *float32 `json:"max"`
|
|
Min *float32 `json:"min"`
|
|
Ts time.Time `json:"ts"`
|
|
Value *float32 `json:"value"`
|
|
}{Value: &f, Ts: ts})
|
|
}
|
|
rows.Close()
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
series.Samples = samples
|
|
items = append(items, series)
|
|
}
|
|
|
|
if items == nil {
|
|
items = []gen.MetricSeries{}
|
|
}
|
|
return gen.QueryMetrics200JSONResponse{Items: items}, nil
|
|
}
|
|
|
|
func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
|
|
entityID, err := s.resolveEntityID(ctx, req.EntityId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
from := time.Now().Add(-7 * 24 * time.Hour)
|
|
if req.Params.From != nil {
|
|
from = *req.Params.From
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT metric,
|
|
ROUND(avg(value)::numeric, 2) AS avg_val,
|
|
ROUND(stddev(value)::numeric, 2) AS std_val,
|
|
count(*) AS sample_count,
|
|
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
|
FROM metric_samples
|
|
WHERE entity_id = $1 AND ts >= $2
|
|
GROUP BY metric
|
|
ORDER BY metric`, entityID, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.Trend{}
|
|
for rows.Next() {
|
|
var t gen.Trend
|
|
var avgVal, stdVal, slopeNum pgtype.Numeric
|
|
var sampleCount int
|
|
if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Determine direction.
|
|
if slopeNum.Valid {
|
|
f, _ := slopeNum.Float64Value()
|
|
t.Slope = float32Ptr(float32(f.Float64))
|
|
if f.Float64 > 0.01 {
|
|
t.Direction = gen.TrendDirectionImproving
|
|
} else if f.Float64 < -0.01 {
|
|
t.Direction = gen.TrendDirectionDegrading
|
|
} else {
|
|
t.Direction = gen.TrendDirectionStable
|
|
}
|
|
} else {
|
|
t.Direction = gen.TrendDirectionUnknown
|
|
}
|
|
items = append(items, t)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
if items == nil {
|
|
items = []gen.Trend{}
|
|
}
|
|
return gen.GetTrends200JSONResponse{Items: items}, nil
|
|
}
|
|
|
|
func float32Ptr(f float32) *float32 {
|
|
return &f
|
|
}
|