From c96c79512628f30ecbb0a81fc0e75ee76503f251 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 17 Jul 2026 22:54:44 +0200 Subject: [PATCH] test: add unit tests for 6 previously-untested packages (R7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added pure unit tests for all packages that had 0% coverage. Where pure logic was entangled with DB calls, extracted testable helpers first. internal/domain (0% -> 100%): - TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors, TestSignalTransitionsComplete internal/learning (0% -> 26.2%): - Refactored processGroup to extract 4 pure helpers: countOutcomes, computeConfidence, shouldValidate, shouldQuarantine - TestWilsonLowerBound (monotonicity, edge cases, sample-size cap) - TestCountOutcomes, TestComputeConfidence, TestShouldValidate, TestShouldQuarantine (table-driven) - Remaining gap: extractPatterns/processGroup DB calls need make test-db internal/policy (39% -> 50%): - Extracted determineRoute from ClassifySignal (pure route logic) - TestDetermineRoute (7 cases covering global/entity kill-switches, approval) - Remaining gap: ClassifySignal/computeBlastRadius need DB mock internal/knowledge (0% -> 14.2%): - TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray - Documented latent bug: toPGArray doesn't escape " or \\ in tags - Remaining gap: ingest* functions need make test-db internal/actuator (0% -> 14.7%): - TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock) - TestParseProcedure, TestSetDefaultSSHTimeout - Circuit breaker full state-machine test (open/close/reset/per-target) - Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures internal/scheduler (0% -> 7.3%): - TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript - TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback) - Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB internal/notifier (0% -> 6.4%): - TestHashToken, TestGenerateApprovalToken (HMAC re-derivation) - Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need make test-db All tests pass with -race. domain hits its 60% gate at 100%. The remaining packages need integration tests (make test-db) and/or httptest-based tests to reach their coverage gates — tracked as follow-up. --- internal/actuator/circuit_breaker_test.go | 109 +++++++++ internal/actuator/ssh_test.go | 151 +++++++++++++ internal/domain/domain_test.go | 139 ++++++++++++ internal/knowledge/seed_test.go | 155 +++++++++++++ internal/learning/learning.go | 62 +++-- internal/learning/learning_test.go | 211 ++++++++++++++++++ internal/notifier/notifier_test.go | 187 ++++++++++++++++ internal/policy/classify.go | 31 +-- internal/policy/classify_test.go | 62 +++++ internal/scheduler/scheduler_test.go | 208 +++++++++++++++++ .../2026-07-17-codebase-review-and-cleanup.md | 2 +- 11 files changed, 1282 insertions(+), 35 deletions(-) create mode 100644 internal/actuator/circuit_breaker_test.go create mode 100644 internal/actuator/ssh_test.go create mode 100644 internal/domain/domain_test.go create mode 100644 internal/knowledge/seed_test.go create mode 100644 internal/learning/learning_test.go create mode 100644 internal/notifier/notifier_test.go create mode 100644 internal/policy/classify_test.go create mode 100644 internal/scheduler/scheduler_test.go diff --git a/internal/actuator/circuit_breaker_test.go b/internal/actuator/circuit_breaker_test.go new file mode 100644 index 0000000..6b0d6e6 --- /dev/null +++ b/internal/actuator/circuit_breaker_test.go @@ -0,0 +1,109 @@ +package actuator + +import ( + "testing" + "time" +) + +func TestNewCircuitBreakerDefaults(t *testing.T) { + cb := newCircuitBreaker(0, 0) + if cb.threshold != 3 { + t.Errorf("threshold = %d, want 3", cb.threshold) + } + if cb.cooldownS != 300 { + t.Errorf("cooldownS = %d, want 300", cb.cooldownS) + } + + cb = newCircuitBreaker(5, 60) + if cb.threshold != 5 { + t.Errorf("threshold = %d, want 5", cb.threshold) + } + if cb.cooldownS != 60 { + t.Errorf("cooldownS = %d, want 60", cb.cooldownS) + } +} + +func TestCircuitBreakerIsOpenFresh(t *testing.T) { + cb := newCircuitBreaker(3, 60) + if cb.isOpen("host:A") { + t.Errorf("fresh circuit should be closed, got open") + } +} + +func TestCircuitBreakerOpensAtThreshold(t *testing.T) { + cb := newCircuitBreaker(3, 60) + // threshold-1 failures → still closed + cb.recordFailure("host:A") + cb.recordFailure("host:A") + if cb.isOpen("host:A") { + t.Fatalf("circuit should be closed after threshold-1 failures") + } + // one more → open + cb.recordFailure("host:A") + if !cb.isOpen("host:A") { + t.Fatalf("circuit should be open after threshold failures") + } +} + +func TestCircuitBreakerClosesAfterCooldown(t *testing.T) { + cb := newCircuitBreaker(1, 60) + // Force open + cb.recordFailure("host:A") + if !cb.isOpen("host:A") { + t.Fatalf("circuit should be open") + } + // Manipulate the cooldown timestamp to the past to simulate expiry. + cb.mu.Lock() + cb.cooldowns["host:A"] = time.Now().Add(-1 * time.Second) + cb.mu.Unlock() + + if cb.isOpen("host:A") { + t.Fatalf("circuit should be closed after cooldown expired") + } + // Failure count should have been reset by isOpen. + cb.mu.Lock() + got := cb.failures["host:A"] + cb.mu.Unlock() + if got != 0 { + t.Errorf("failure count after cooldown reset = %d, want 0", got) + } +} + +func TestCircuitBreakerRecordSuccessResets(t *testing.T) { + cb := newCircuitBreaker(3, 60) + cb.recordFailure("host:A") + cb.recordFailure("host:A") + + cb.recordSuccess("host:A") + + cb.mu.Lock() + got := cb.failures["host:A"] + cb.mu.Unlock() + if got != 0 { + t.Errorf("failure count after success = %d, want 0", got) + } + if cb.isOpen("host:A") { + t.Errorf("circuit should be closed after success reset") + } +} + +func TestCircuitBreakerPerTargetIsolation(t *testing.T) { + cb := newCircuitBreaker(2, 60) + cb.recordFailure("host:A") + cb.recordFailure("host:A") // host:A now at threshold → open + + if !cb.isOpen("host:A") { + t.Fatalf("host:A should be open") + } + if cb.isOpen("host:B") { + t.Errorf("host:B should be closed (isolated from host:A)") + } + + // host:B has no failures recorded + cb.mu.Lock() + gotB := cb.failures["host:B"] + cb.mu.Unlock() + if gotB != 0 { + t.Errorf("host:B failure count = %d, want 0", gotB) + } +} diff --git a/internal/actuator/ssh_test.go b/internal/actuator/ssh_test.go new file mode 100644 index 0000000..0abc5f7 --- /dev/null +++ b/internal/actuator/ssh_test.go @@ -0,0 +1,151 @@ +package actuator + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestSSHErrorClassString(t *testing.T) { + cases := []struct { + class SSHErrorClass + want string + }{ + {SSHErrorNetwork, "network"}, + {SSHErrorAuth, "auth"}, + {SSHErrorTimeout, "timed_out"}, + {SSHErrorRemote, "remote"}, + {SSHErrorOther, "other"}, + {SSHErrorClass(999), "unknown"}, + } + for _, c := range cases { + if got := c.class.String(); got != c.want { + t.Errorf("SSHErrorClass(%d).String() = %q, want %q", c.class, got, c.want) + } + } +} + +// timeoutNetErr is a custom net.Error implementation for testing. +type timeoutNetErr struct { + timeout bool + msg string +} + +func (e *timeoutNetErr) Error() string { return e.msg } +func (e *timeoutNetErr) Timeout() bool { return e.timeout } +func (e *timeoutNetErr) Temporary() bool { return false } + +func TestClassifySSHError(t *testing.T) { + // ssh.ExitError fields are unexported, but classifySSHError only checks + // for the type via errors.As, so the zero value is sufficient. + exitErr := &ssh.ExitError{} + + cases := []struct { + name string + err error + want SSHErrorClass + }{ + {"nil", nil, SSHErrorOther}, + {"deadline exceeded", context.DeadlineExceeded, SSHErrorTimeout}, + {"net error timeout true", &timeoutNetErr{timeout: true, msg: "i/o timeout"}, SSHErrorNetwork}, + {"net error timeout false", &timeoutNetErr{timeout: false, msg: "connection refused"}, SSHErrorNetwork}, + {"unable to authenticate", errors.New("unable to authenticate, no supported methods remain"), SSHErrorAuth}, + {"no supported methods remain", errors.New("no supported methods remain (server sent publickey)"), SSHErrorAuth}, + {"ssh handshake failed", errors.New("ssh: handshake failed: read tcp -> eof"), SSHErrorAuth}, + {"publickey", errors.New("publickey denied"), SSHErrorAuth}, + {"permission denied", errors.New("permission denied (publickey)"), SSHErrorAuth}, + {"exit error", exitErr, SSHErrorRemote}, + {"generic error", errors.New("something went wrong"), SSHErrorOther}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := classifySSHError(c.err); got != c.want { + t.Errorf("classifySSHError(%v) = %v, want %v", c.err, got, c.want) + } + }) + } +} + +func TestParseProcedure(t *testing.T) { + cases := []struct { + name string + data []byte + wantErr bool + wantLen int + }{ + { + name: "valid with steps", + data: []byte(`{"steps":[{"runner":"shell","command":"echo hi"}]}`), + wantErr: false, + wantLen: 1, + }, + { + name: "invalid json", + data: []byte(`{not json`), + wantErr: true, + }, + { + name: "empty bytes", + data: []byte{}, + wantErr: true, + }, + { + name: "valid no steps key", + data: []byte(`{"foo":"bar"}`), + wantErr: false, + wantLen: 0, + }, + { + name: "valid with extra fields", + data: []byte(`{"extra":"ignored","steps":[{"runner":"verify","command":"true"}],"more":123}`), + wantErr: false, + wantLen: 1, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + proc, err := ParseProcedure(c.data) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (proc=%+v)", proc) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(proc.Steps) != c.wantLen { + t.Errorf("got %d steps, want %d", len(proc.Steps), c.wantLen) + } + }) + } +} + +func TestSetDefaultSSHTimeout(t *testing.T) { + mu.Lock() + orig := defaultSSHTimeout + mu.Unlock() + defer func() { + mu.Lock() + defaultSSHTimeout = orig + mu.Unlock() + }() + + newTimeout := 42 * time.Second + SetDefaultSSHTimeout(newTimeout) + + mu.Lock() + got := defaultSSHTimeout + mu.Unlock() + + if got != newTimeout { + t.Errorf("defaultSSHTimeout = %v, want %v", got, newTimeout) + } +} + +// Ensure timeoutNetErr satisfies net.Error at compile time. +var _ net.Error = (*timeoutNetErr)(nil) diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..9b887ed --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,139 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestIsNil(t *testing.T) { + cases := []struct { + name string + u UUID + want bool + }{ + {"empty string", UUID(""), true}, + {"single char", UUID("x"), false}, + {"uuid string", UUID("550e8400-e29b-41d4-a716-446655440000"), false}, + {"nil literal", UUID(""), true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.u.IsNil() + if got != c.want { + t.Errorf("UUID(%q).IsNil() = %v, want %v", c.u, got, c.want) + } + }) + } +} + +func TestCanTransition(t *testing.T) { + type tc struct { + name string + from string + to string + want bool + } + var cases []tc + + for from, targets := range ValidSignalTransitions { + for _, to := range targets { + cases = append(cases, tc{from + "->" + to, from, to, true}) + } + } + + disallowed := []tc{ + {"raised->raised", SignalRaised, SignalRaised, false}, + {"resolved->raised", SignalResolved, SignalRaised, false}, + {"failed->raised", SignalFailed, SignalRaised, false}, + {"acknowledged->raised", SignalAcknowledged, SignalRaised, false}, + {"muted->resolved", SignalMuted, SignalResolved, false}, + {"acting->acknowledged", SignalActing, SignalAcknowledged, false}, + } + cases = append(cases, disallowed...) + + cases = append(cases, + tc{"unknown source", "nonexistent", SignalRaised, false}, + tc{"unknown target", SignalRaised, "nonexistent", false}, + ) + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := &Signal{State: c.from} + got := s.CanTransition(c.to) + if got != c.want { + t.Errorf("CanTransition(%q -> %q) = %v, want %v", c.from, c.to, got, c.want) + } + }) + } +} + +func TestSentinelErrors(t *testing.T) { + cases := []struct { + name string + err error + msg string + }{ + {"ErrNotFound", ErrNotFound, "entity not found"}, + {"ErrInvalidTransition", ErrInvalidTransition, "invalid lifecycle transition"}, + {"ErrApprovalRequired", ErrApprovalRequired, "operator approval required"}, + {"ErrAutonomyBlocked", ErrAutonomyBlocked, "autonomy policy blocks this action"}, + {"ErrConflict", ErrConflict, "concurrent modification conflict"}, + {"ErrCircuitOpen", ErrCircuitOpen, "circuit breaker open for target"}, + {"ErrAbstractType", ErrAbstractType, "cannot instantiate abstract entity type"}, + {"ErrInvalidEdge", ErrInvalidEdge, "relationship endpoint type mismatch"}, + {"ErrCardinality", ErrCardinality, "relationship cardinality violation"}, + {"ErrSeedHashMismatch", ErrSeedHashMismatch, "seed content hash mismatch"}, + {"ErrAlreadyExists", ErrAlreadyExists, "entity already exists"}, + {"ErrQuarantined", ErrQuarantined, "pattern is quarantined"}, + {"ErrSkillDeprecated", ErrSkillDeprecated, "skill is deprecated"}, + {"ErrInvalidInput", ErrInvalidInput, "invalid input"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.err == nil { + t.Fatal("sentinel error is nil") + } + if !errors.Is(c.err, c.err) { + t.Errorf("errors.Is failed for %s", c.name) + } + if c.err.Error() != c.msg { + t.Errorf("Error() = %q, want %q", c.err.Error(), c.msg) + } + }) + } +} + +func TestSignalTransitionsComplete(t *testing.T) { + // Non-terminal states must be keys in ValidSignalTransitions. + // SignalResolved is a terminal state (no outgoing transitions) and is + // intentionally absent from the map. + nonTerminal := []string{ + SignalRaised, + SignalAcknowledged, + SignalActing, + SignalMuted, + SignalFailed, + } + for _, state := range nonTerminal { + targets, ok := ValidSignalTransitions[state] + if !ok { + t.Errorf("non-terminal state %q missing from ValidSignalTransitions", state) + continue + } + if len(targets) == 0 { + t.Errorf("state %q maps to empty transition list", state) + } + } + + // Resolved is terminal: it should not appear as a source key. + if _, ok := ValidSignalTransitions[SignalResolved]; ok { + t.Errorf("terminal state %q should not have outgoing transitions", SignalResolved) + } + + // No state anywhere in the map may map to nil/empty. + for state, targets := range ValidSignalTransitions { + if len(targets) == 0 { + t.Errorf("state %q maps to empty/nil transition list", state) + } + } +} diff --git a/internal/knowledge/seed_test.go b/internal/knowledge/seed_test.go new file mode 100644 index 0000000..b0106fd --- /dev/null +++ b/internal/knowledge/seed_test.go @@ -0,0 +1,155 @@ +package knowledge + +import ( + "crypto/sha256" + "encoding/hex" + "reflect" + "testing" +) + +func TestContentHash(t *testing.T) { + t.Run("determinism", func(t *testing.T) { + a := contentHash("hello") + b := contentHash("hello") + if a != b { + t.Errorf("contentHash not deterministic: %q != %q", a, b) + } + }) + + t.Run("empty string known sha256", func(t *testing.T) { + got := contentHash("") + h := sha256.Sum256([]byte("")) + want := hex.EncodeToString(h[:]) + if got != want { + t.Errorf("contentHash(\"\") = %q, want %q", got, want) + } + }) + + t.Run("different inputs different outputs", func(t *testing.T) { + if contentHash("a") == contentHash("b") { + t.Error("different inputs produced same hash") + } + }) + + t.Run("output is 64-char hex", func(t *testing.T) { + got := contentHash("anything") + if len(got) != 64 { + t.Errorf("len = %d, want 64", len(got)) + } + for _, r := range got { + isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') + if !isHex { + t.Errorf("non-hex char %q in hash %q", r, got) + break + } + } + }) +} + +func TestStr(t *testing.T) { + cases := []struct { + name string + m map[string]any + key string + want string + }{ + {"missing key", map[string]any{}, "nope", ""}, + {"string value", map[string]any{"k": "v"}, "k", "v"}, + {"int value", map[string]any{"k": 42}, "k", ""}, + {"nil value", map[string]any{"k": nil}, "k", ""}, + {"empty string", map[string]any{"k": ""}, "k", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := str(c.m, c.key) + if got != c.want { + t.Errorf("str() = %q, want %q", got, c.want) + } + }) + } +} + +func TestStrSlice(t *testing.T) { + cases := []struct { + name string + m map[string]any + key string + want []string + }{ + {"missing key", map[string]any{}, "tags", nil}, + {"all strings", map[string]any{"tags": []any{"a", "b", "c"}}, "tags", []string{"a", "b", "c"}}, + {"mixed types", map[string]any{"tags": []any{1, "a", true, "b"}}, "tags", []string{"a", "b"}}, + {"empty array", map[string]any{"tags": []any{}}, "tags", []string{}}, + {"nil elements filtered", map[string]any{"tags": []any{nil, "a", nil, "b"}}, "tags", []string{"a", "b"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := strSlice(c.m, c.key) + if len(got) != len(c.want) { + t.Errorf("len = %d, want %d (got %v)", len(got), len(c.want), got) + return + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], c.want[i]) + } + } + }) + } +} + +func TestMapVal(t *testing.T) { + cases := []struct { + name string + m map[string]any + key string + want map[string]any + }{ + {"missing key", map[string]any{}, "nope", nil}, + {"present map", map[string]any{"k": map[string]any{"x": 1}}, "k", map[string]any{"x": 1}}, + {"wrong type string", map[string]any{"k": "v"}, "k", nil}, + {"nested map", map[string]any{"k": map[string]any{"a": map[string]any{"b": 2}}}, "k", map[string]any{"a": map[string]any{"b": 2}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := mapVal(c.m, c.key) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("mapVal() = %v, want %v", got, c.want) + } + }) + } +} + +func TestToPGArray(t *testing.T) { + cases := []struct { + name string + tags []string + want string + }{ + {"empty", []string{}, "{}"}, + {"single", []string{"a"}, `{"a"}`}, + {"multiple", []string{"a", "b"}, `{"a","b"}`}, + {"nil", nil, "{}"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := toPGArray(c.tags) + if got != c.want { + t.Errorf("toPGArray() = %q, want %q", got, c.want) + } + }) + } + + // Special chars: tags containing " or \ are NOT escaped by toPGArray. + // This is a latent bug — Postgres array literals require these to be + // backslash-escaped. Test documents current behavior so a fix is + // detectable. Should be fixed. + t.Run("special chars unescaped (current buggy behavior)", func(t *testing.T) { + got := toPGArray([]string{`a"b`, `c\d`}) + // Current output: {"a"b","c\d"} — invalid Postgres array literal. + want := `{"a"b","c\d"}` + if got != want { + t.Errorf("toPGArray(special) = %q, want %q (if this changed, the escaping bug was fixed — update this test)", got, want) + } + }) +} diff --git a/internal/learning/learning.go b/internal/learning/learning.go index 9d8bfee..f0aa7c5 100644 --- a/internal/learning/learning.go +++ b/internal/learning/learning.go @@ -83,27 +83,13 @@ func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) ti func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) { - successCount := 0 - failureCount := 0 - for _, f := range items { - switch f.Outcome { - case "success": - successCount++ - case "failure", "unexpected": - failureCount++ - case "partial": - successCount++ // partial counts as half-success - } - } + successCount, failureCount := countOutcomes(items) total := successCount + failureCount if total == 0 { return } - // Compute Wilson score lower bound - confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95) - // Cap by sample size: nothing looks confident before 5 samples - confidence = math.Min(confidence, float64(total)/5.0) + confidence := computeConfidence(successCount, failureCount) // Get or create pattern — first look up existing entity, then upsert. existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{ @@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, return } - if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined { + if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) { _ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{ EntityID: pat.EntityID, Status: "validated", @@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, "confidence", confidence, "samples", total) } - // Anomaly check: >10 identical outcomes within 1h - if total > 10 { + if shouldQuarantine(total) { _ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{ EntityID: pat.EntityID, Quarantined: true, @@ -169,6 +154,45 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, } } +// countOutcomes tallies feedback items into success and failure counts. +// "partial" counts as a half-success (increments success). +func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) { + for _, f := range items { + switch f.Outcome { + case "success": + success++ + case "failure", "unexpected": + failure++ + case "partial": + success++ + } + } + return success, failure +} + +// computeConfidence calculates the Wilson score lower bound, capped by +// sample size (nothing looks confident before 5 samples). +func computeConfidence(success, failure int) float64 { + total := success + failure + if total == 0 { + return 0 + } + confidence := wilsonLowerBound(float64(success), float64(total), 0.95) + return math.Min(confidence, float64(total)/5.0) +} + +// shouldValidate returns true when a pattern has enough evidence and +// confidence to be promoted from "hypothesized" to "validated". +func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool { + return evidenceCount >= 5 && confidence >= 0.7 && !quarantined +} + +// shouldQuarantine returns true when an anomaly burst is detected +// (>10 identical outcomes, indicating a runaway loop rather than organic feedback). +func shouldQuarantine(total int) bool { + return total > 10 +} + // wilsonLowerBound computes the Wilson score interval lower bound. // Conservative estimate of success rate for small sample sizes. func wilsonLowerBound(success, total, z float64) float64 { diff --git a/internal/learning/learning_test.go b/internal/learning/learning_test.go new file mode 100644 index 0000000..0ff4fc5 --- /dev/null +++ b/internal/learning/learning_test.go @@ -0,0 +1,211 @@ +package learning + +import ( + "math" + "testing" + + "github.com/dtoro/oikos/internal/db/sqlcgen" +) + +func TestWilsonLowerBound(t *testing.T) { + tests := []struct { + name string + success float64 + total float64 + z float64 + want float64 + // For approximate checks + approx bool + epsilon float64 + }{ + {"zero total", 5, 0, 1.96, 0, false, 0}, + {"zero success", 0, 10, 1.96, 0, true, 1e-10}, + {"all success small n", 3, 3, 1.96, 0, true, 0.5}, + {"all success large n", 100, 100, 1.96, 0, true, 0.05}, + {"half success large n", 50, 100, 1.96, 0.39, true, 0.02}, + {"higher z gives lower bound", 8, 10, 3.0, 0, true, 0.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wilsonLowerBound(tt.success, tt.total, tt.z) + if tt.approx { + if tt.want > 0 && math.Abs(got-tt.want) > tt.epsilon { + t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want ~%v (±%v)", tt.success, tt.total, tt.z, got, tt.want, tt.epsilon) + } + if got < 0 { + t.Errorf("wilsonLowerBound returned negative: %v", got) + } + if got > 1 { + t.Errorf("wilsonLowerBound returned >1: %v", got) + } + } else { + if got != tt.want { + t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want %v", tt.success, tt.total, tt.z, got, tt.want) + } + } + }) + } +} + +func TestWilsonLowerBoundMonotonic(t *testing.T) { + prev := 0.0 + for s := 0.0; s <= 20; s++ { + got := wilsonLowerBound(s, 20, 1.96) + if got < prev-1e-9 { + t.Errorf("not monotonically increasing: s=%v got=%v prev=%v", s, got, prev) + } + prev = got + } +} + +func TestCountOutcomes(t *testing.T) { + tests := []struct { + name string + items []sqlcgen.GetFeedbackAfterWatermarkRow + wantSuccess int + wantFailure int + }{ + { + "empty", + nil, + 0, 0, + }, + { + "all success", + []sqlcgen.GetFeedbackAfterWatermarkRow{ + {Outcome: "success"}, + {Outcome: "success"}, + }, + 2, 0, + }, + { + "all failure", + []sqlcgen.GetFeedbackAfterWatermarkRow{ + {Outcome: "failure"}, + {Outcome: "unexpected"}, + }, + 0, 2, + }, + { + "mixed including partial", + []sqlcgen.GetFeedbackAfterWatermarkRow{ + {Outcome: "success"}, + {Outcome: "failure"}, + {Outcome: "partial"}, + {Outcome: "unexpected"}, + }, + 2, 2, + }, + { + "unknown outcome ignored", + []sqlcgen.GetFeedbackAfterWatermarkRow{ + {Outcome: "success"}, + {Outcome: "bogus"}, + }, + 1, 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, f := countOutcomes(tt.items) + if s != tt.wantSuccess || f != tt.wantFailure { + t.Errorf("countOutcomes() = (%d, %d), want (%d, %d)", s, f, tt.wantSuccess, tt.wantFailure) + } + }) + } +} + +func TestComputeConfidence(t *testing.T) { + tests := []struct { + name string + success int + failure int + wantMax float64 + wantMin float64 + }{ + {"zero total", 0, 0, 0, 0}, + {"one success no failures", 1, 0, 0.2, 0}, + {"five success no failures", 5, 0, 1.0, 0.3}, + {"ten success no failures", 10, 0, 1.0, 0.5}, + {"half success", 5, 5, 0.5, 0.2}, + {"all failures", 0, 10, 0.01, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeConfidence(tt.success, tt.failure) + if got < 0 || got > 1 { + t.Errorf("confidence out of [0,1]: %v", got) + } + if got > tt.wantMax+0.01 { + t.Errorf("confidence too high: got %v, max ~%v", got, tt.wantMax) + } + if tt.wantMin > 0 && got < tt.wantMin-0.1 { + t.Errorf("confidence too low: got %v, min ~%v", got, tt.wantMin) + } + }) + } +} + +func TestComputeConfidenceSampleSizeCap(t *testing.T) { + got := computeConfidence(1, 0) + if got > 0.2+1e-9 { + t.Errorf("sample size cap not applied: 1 sample should cap at 1/5=0.2, got %v", got) + } + got = computeConfidence(4, 0) + if got > 0.8+1e-9 { + t.Errorf("sample size cap not applied: 4 samples should cap at 4/5=0.8, got %v", got) + } +} + +func TestShouldValidate(t *testing.T) { + tests := []struct { + name string + evidenceCount int + confidence float64 + quarantined bool + want bool + }{ + {"enough evidence and confidence", 5, 0.7, false, true}, + {"high evidence high confidence", 10, 0.9, false, true}, + {"not enough evidence", 4, 0.9, false, false}, + {"not enough confidence", 5, 0.69, false, false}, + {"quarantined blocks validation", 10, 0.9, true, false}, + {"exactly at threshold", 5, 0.7, false, true}, + {"zero evidence", 0, 0.9, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldValidate(tt.evidenceCount, tt.confidence, tt.quarantined) + if got != tt.want { + t.Errorf("shouldValidate(%d, %v, %v) = %v, want %v", tt.evidenceCount, tt.confidence, tt.quarantined, got, tt.want) + } + }) + } +} + +func TestShouldQuarantine(t *testing.T) { + tests := []struct { + name string + total int + want bool + }{ + {"zero", 0, false}, + {"small", 5, false}, + {"at threshold", 10, false}, + {"over threshold", 11, true}, + {"large burst", 50, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldQuarantine(tt.total) + if got != tt.want { + t.Errorf("shouldQuarantine(%d) = %v, want %v", tt.total, got, tt.want) + } + }) + } +} diff --git a/internal/notifier/notifier_test.go b/internal/notifier/notifier_test.go new file mode 100644 index 0000000..35de6bb --- /dev/null +++ b/internal/notifier/notifier_test.go @@ -0,0 +1,187 @@ +package notifier + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +var hex64Re = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func TestHashToken(t *testing.T) { + // sha256("") == e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + emptyHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + t.Run("determinism same input same output", func(t *testing.T) { + a := hashToken("approval-token-123") + b := hashToken("approval-token-123") + if a != b { + t.Fatalf("hashToken not deterministic: %q vs %q", a, b) + } + }) + + t.Run("empty string known sha256", func(t *testing.T) { + got := hashToken("") + if got != emptyHash { + t.Fatalf("hashToken(\"\") = %q, want %q", got, emptyHash) + } + }) + + t.Run("different inputs different outputs", func(t *testing.T) { + a := hashToken("one") + b := hashToken("two") + if a == b { + t.Fatalf("hashToken collided for different inputs: %q", a) + } + }) + + t.Run("output is valid 64-char hex", func(t *testing.T) { + for _, in := range []string{"", "abc", "some-longer-token-value-xyz"} { + got := hashToken(in) + if !hex64Re.MatchString(got) { + t.Fatalf("hashToken(%q) = %q, not 64-char lowercase hex", in, got) + } + // also must decode cleanly to 32 bytes + b, err := hex.DecodeString(got) + if err != nil { + t.Fatalf("hashToken(%q) decode error: %v", in, err) + } + if len(b) != 32 { + t.Fatalf("hashToken(%q) decoded len = %d, want 32", in, len(b)) + } + } + }) +} + +func TestGenerateApprovalToken(t *testing.T) { + id := uuid.New() + + t.Run("output is 64-char hex", func(t *testing.T) { + tok := generateApprovalToken(id, "super-secret") + if !hex64Re.MatchString(tok) { + t.Fatalf("generateApprovalToken = %q, not 64-char lowercase hex", tok) + } + b, err := hex.DecodeString(tok) + if err != nil { + t.Fatalf("decode error: %v", err) + } + if len(b) != 32 { + t.Fatalf("decoded len = %d, want 32 (sha256)", len(b)) + } + }) + + t.Run("empty secret falls back to dev secret no panic", func(t *testing.T) { + tok := generateApprovalToken(id, "") + if tok == "" { + t.Fatal("empty secret produced empty token") + } + if !hex64Re.MatchString(tok) { + t.Fatalf("empty-secret token %q not 64-char hex", tok) + } + }) + + // Non-determinism: time.Now().UnixNano() is embedded in the HMAC message, + // so two calls with identical inputs produce different tokens (unless the + // clock has nanosecond-identical reads, which we do not assert against). + t.Run("same inputs twice produce different tokens (time-based)", func(t *testing.T) { + a := generateApprovalToken(id, "stable-secret") + b := generateApprovalToken(id, "stable-secret") + if a == b { + // Not a hard failure (clock granularity), but document expectation. + t.Logf("note: two immediate calls returned identical token %q — clock resolution collapsed", a) + } + }) + + t.Run("different secrets produce different tokens", func(t *testing.T) { + a := generateApprovalToken(id, "secret-a") + b := generateApprovalToken(id, "secret-b") + if a == b { + t.Fatalf("different secrets produced same token %q", a) + } + }) + + // HMAC correctness: re-derive the token with the same secret + approvalID + // using a freshly captured timestamp window is impossible because we don't + // observe the embedded timestamp. Instead, verify the token is a valid + // HMAC-SHA256 by brute-forcing a small time window around now: reconstruct + // mac(secret, approvalID || ts) for ts in [now-N, now] and confirm one + // matches. This proves the token genuinely is an HMAC over (approvalID, ts) + // with the supplied secret. + t.Run("token is HMAC-SHA256 over approvalID+timestamp with secret", func(t *testing.T) { + secret := "hmac-verify-secret" + before := nowNanos() + tok := generateApprovalToken(id, secret) + after := nowNanos() + + // The token's embedded ts is captured inside generateApprovalToken, + // which is called after `before` was sampled — so ts ∈ [before, after]. + // Add a tiny ±band to absorb scheduler jitter on loaded runners. + lo := before - 10_000 + hi := after + 10_000 + matched := false + for ts := lo; ts <= hi; ts++ { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(id.String())) + mac.Write([]byte(formatInt(ts))) + cand := hex.EncodeToString(mac.Sum(nil)) + if hmac.Equal([]byte(cand), []byte(tok)) { + matched = true + break + } + } + if !matched { + t.Fatalf("token %q did not match any HMAC in window [%d,%d]; not a valid HMAC-SHA256 over approvalID+ts", tok, lo, hi) + } + }) + + t.Run("empty secret HMAC uses dev fallback secret", func(t *testing.T) { + before := nowNanos() + tok := generateApprovalToken(id, "") + after := nowNanos() + dev := "dev-secret-do-not-use-in-prod" + lo := before - 10_000 + hi := after + 10_000 + matched := false + for ts := lo; ts <= hi; ts++ { + mac := hmac.New(sha256.New, []byte(dev)) + mac.Write([]byte(id.String())) + mac.Write([]byte(formatInt(ts))) + cand := hex.EncodeToString(mac.Sum(nil)) + if hmac.Equal([]byte(cand), []byte(tok)) { + matched = true + break + } + } + if !matched { + t.Fatalf("empty-secret token %q did not match dev-fallback HMAC", tok) + } + }) + + // sanity: token should not leak the secret in plaintext + t.Run("token does not contain secret substring", func(t *testing.T) { + secret := "leakcheck-secret-xyz" + tok := generateApprovalToken(id, secret) + if strings.Contains(tok, secret) { + t.Fatalf("token %q contains secret substring %q", tok, secret) + } + }) +} + +// nowNanos returns the current nanosecond count, matching the time source +// used by generateApprovalToken (time.Now().UnixNano()). +func nowNanos() int64 { + return time.Now().UnixNano() +} + +// formatInt mirrors fmt.Sprintf("%d", ...) used by the production code so the +// re-derivation in tests is byte-identical. +func formatInt(n int64) string { + return fmt.Sprintf("%d", n) +} diff --git a/internal/policy/classify.go b/internal/policy/classify.go index 9e1696e..3d57f3c 100644 --- a/internal/policy/classify.go +++ b/internal/policy/classify.go @@ -92,21 +92,7 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE } // Determine route - route := "escalate" - autonomyCheck := "" - - if globalAutoAct == "off" || globalAutoAct == "false" { - route = "escalate" - autonomyCheck = "blocked: global auto_act disabled" - } else if entityAutoAct == "true" { - route = "escalate" - autonomyCheck = "blocked: per-entity kill-switch" - } else if approvalRequired == "none" { - route = "auto-act" - autonomyCheck = "allowed" - } else { - autonomyCheck = "requires approval: " + approvalRequired - } + route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired) // Compute blast radius blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID) @@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE }, nil } +// determineRoute evaluates autonomy settings and approval requirements to +// decide whether a signal should auto-act, escalate, or hold for approval. +func determineRoute(globalAutoAct, entityAutoAct, approvalRequired string) (route, autonomyCheck string) { + if globalAutoAct == "off" || globalAutoAct == "false" { + return "escalate", "blocked: global auto_act disabled" + } + if entityAutoAct == "true" { + return "escalate", "blocked: per-entity kill-switch" + } + if approvalRequired == "none" { + return "auto-act", "allowed" + } + return "escalate", "requires approval: " + approvalRequired +} + // computeBlastRadius traverses relationships to find affected entities. func computeBlastRadius(ctx context.Context, dbc interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) diff --git a/internal/policy/classify_test.go b/internal/policy/classify_test.go new file mode 100644 index 0000000..2f9440d --- /dev/null +++ b/internal/policy/classify_test.go @@ -0,0 +1,62 @@ +package policy + +import "testing" + +func TestDetermineRoute(t *testing.T) { + tests := []struct { + name string + globalAutoAct string + entityAutoAct string + approvalRequired string + wantRoute string + wantCheck string + }{ + { + "global auto_act off blocks everything", + "off", "", "none", + "escalate", "blocked: global auto_act disabled", + }, + { + "global auto_act false blocks everything", + "false", "", "none", + "escalate", "blocked: global auto_act disabled", + }, + { + "per-entity kill-switch blocks", + "on", "true", "none", + "escalate", "blocked: per-entity kill-switch", + }, + { + "approval none auto-acts", + "on", "", "none", + "auto-act", "allowed", + }, + { + "approval required escalates", + "on", "", "operator", + "escalate", "requires approval: operator", + }, + { + "approval none with empty global defaults to auto-act", + "", "", "none", + "auto-act", "allowed", + }, + { + "global on, no entity kill, approval confirmation", + "on", "", "confirmation", + "escalate", "requires approval: confirmation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + route, check := determineRoute(tt.globalAutoAct, tt.entityAutoAct, tt.approvalRequired) + if route != tt.wantRoute { + t.Errorf("route = %q, want %q", route, tt.wantRoute) + } + if check != tt.wantCheck { + t.Errorf("autonomyCheck = %q, want %q", check, tt.wantCheck) + } + }) + } +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..f7bab2c --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,208 @@ +package scheduler + +import ( + "testing" +) + +func TestParsePingLatency(t *testing.T) { + cases := []struct { + name string + out []byte + want float64 + }{ + { + name: "linux rtt format", + out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nrtt min/avg/max/mdev = 0.1/2.5/5.0/1.2 ms\n"), + want: 2.5, + }, + { + name: "macos round-trip format", + out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nround-trip min/avg/max/stddev = 0.1/2.5/5.0/1.2 ms\n"), + want: 2.5, + }, + { + name: "no match empty string", + out: []byte(""), + want: 0, + }, + { + name: "malformed number in avg slot", + out: []byte("rtt min/avg/max/mdev = 0.1/abc/5.0/1.2 ms\n"), + want: 0, + }, + { + name: "truly empty input nil", + out: nil, + want: 0, + }, + { + name: "linux integer avg", + out: []byte("rtt min/avg/max/mdev = 1/3/5/0.5 ms\n"), + want: 3.0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parsePingLatency(tc.out) + if got != tc.want { + t.Fatalf("parsePingLatency(%q) = %v, want %v", tc.out, got, tc.want) + } + }) + } +} + +func TestAllowlistedScript(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + {name: "cpu_check.sh", in: "cpu_check.sh", want: true}, + {name: "disk_usage_check.sh", in: "disk_usage_check.sh", want: true}, + {name: "hyphen allowed", in: "foo-bar.sh", want: true}, + {name: "underscore allowed", in: "foo_bar.sh", want: true}, + {name: "uppercase rejected", in: "Foo.sh", want: false}, + {name: "bare .sh rejected (no name)", in: ".sh", want: false}, + {name: "path traversal rejected", in: "../etc/passwd", want: false}, + {name: "empty rejected", in: "", want: false}, + // foo.sh.sh: regex ^[a-z][a-z0-9_-]+\.sh$ — the [a-z0-9_-]+ cannot + // cross the first '.', so after matching "foo.sh" the trailing ".sh" + // breaks the $ anchor → no match. + {name: "doubled .sh.sh rejected", in: "foo.sh.sh", want: false}, + {name: "wrong extension rejected", in: "foo.txt", want: false}, + {name: "single char name rejected (needs 2+)", in: "a.sh", want: false}, + {name: "two char name accepted", in: "ab.sh", want: true}, + {name: "digit after first char", in: "a1.sh", want: true}, + {name: "leading digit rejected", in: "1foo.sh", want: false}, + {name: "dot in middle rejected", in: "foo.bar.sh", want: false}, + {name: "space rejected", in: "foo bar.sh", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := allowlistedScript(tc.in) + if got != tc.want { + t.Fatalf("allowlistedScript(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestEvaluateSeverity(t *testing.T) { + cases := []struct { + name string + kind string + signalKind string + config []byte + metrics map[string]float64 + want string + }{ + { + name: "empty config down signal", + kind: "ping", + signalKind: "down", + config: nil, + metrics: map[string]float64{}, + want: "critical", + }, + { + name: "empty config high_temp signal", + kind: "ssh-script", + signalKind: "high_temp", + config: nil, + metrics: map[string]float64{}, + want: "warning", + }, + { + name: "crit only exceeded", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"crit":90}}`), + metrics: map[string]float64{"temp": 95}, + want: "critical", + }, + { + name: "warn only exceeded", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80}}`), + metrics: map[string]float64{"temp": 85}, + want: "warning", + }, + { + name: "warn and crit, warn exceeded only", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"temp": 85}, + want: "warning", + }, + { + name: "warn and crit, crit exceeded", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"temp": 95}, + want: "critical", + }, + { + name: "metric below thresholds falls through to warning", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"temp": 70}, + want: "warning", + }, + { + name: "metric below thresholds falls through to down critical", + kind: "ping", + signalKind: "down", + config: []byte(`{"ping_latency_ms":{"warn":100,"crit":200}}`), + metrics: map[string]float64{"ping_latency_ms": 50}, + want: "critical", + }, + { + name: "crit zero skipped falls through", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"crit":0}}`), + metrics: map[string]float64{"temp": 95}, + want: "warning", + }, + { + name: "metric not in thresholds ignored", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"cpu": 99}, + want: "warning", + }, + { + name: "equal to warn triggers warning", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"temp": 80}, + want: "warning", + }, + { + name: "equal to crit triggers critical", + kind: "ssh-script", + signalKind: "high_temp", + config: []byte(`{"temp":{"warn":80,"crit":90}}`), + metrics: map[string]float64{"temp": 90}, + want: "critical", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := evaluateSeverity(tc.kind, tc.signalKind, tc.config, tc.metrics) + if got != tc.want { + t.Fatalf("evaluateSeverity(%q, %q, %s, %v) = %q, want %q", + tc.kind, tc.signalKind, tc.config, tc.metrics, got, tc.want) + } + }) + } +} diff --git a/plans/2026-07-17-codebase-review-and-cleanup.md b/plans/2026-07-17-codebase-review-and-cleanup.md index 1bab963..a771b49 100644 --- a/plans/2026-07-17-codebase-review-and-cleanup.md +++ b/plans/2026-07-17-codebase-review-and-cleanup.md @@ -428,7 +428,7 @@ the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `b | R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | ✅ done | | R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low | ✅ done | | R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | ✅ done | -| R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | +| R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | ✅ partial — pure unit tests added for all 6 packages; remaining coverage needs integration tests (`make test-db`) | | R8 | Add `eslint`+`prettier`+`vitest` to `web/`; wire `svelte-check`+`tsc` into CI; add `web/` CI job | M | Low | ✅ done | | R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low | | R10 | Replace `` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low |