test: add unit tests for 6 previously-untested packages (R7)
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.
This commit is contained in:
109
internal/actuator/circuit_breaker_test.go
Normal file
109
internal/actuator/circuit_breaker_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
internal/actuator/ssh_test.go
Normal file
151
internal/actuator/ssh_test.go
Normal file
@@ -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)
|
||||||
139
internal/domain/domain_test.go
Normal file
139
internal/domain/domain_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
internal/knowledge/seed_test.go
Normal file
155
internal/knowledge/seed_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||||
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
||||||
|
|
||||||
successCount := 0
|
successCount, failureCount := countOutcomes(items)
|
||||||
failureCount := 0
|
|
||||||
for _, f := range items {
|
|
||||||
switch f.Outcome {
|
|
||||||
case "success":
|
|
||||||
successCount++
|
|
||||||
case "failure", "unexpected":
|
|
||||||
failureCount++
|
|
||||||
case "partial":
|
|
||||||
successCount++ // partial counts as half-success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total := successCount + failureCount
|
total := successCount + failureCount
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute Wilson score lower bound
|
confidence := computeConfidence(successCount, failureCount)
|
||||||
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)
|
|
||||||
|
|
||||||
// Get or create pattern — first look up existing entity, then upsert.
|
// Get or create pattern — first look up existing entity, then upsert.
|
||||||
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
||||||
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
|||||||
return
|
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{
|
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
|
||||||
EntityID: pat.EntityID,
|
EntityID: pat.EntityID,
|
||||||
Status: "validated",
|
Status: "validated",
|
||||||
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
|||||||
"confidence", confidence, "samples", total)
|
"confidence", confidence, "samples", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anomaly check: >10 identical outcomes within 1h
|
if shouldQuarantine(total) {
|
||||||
if total > 10 {
|
|
||||||
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
||||||
EntityID: pat.EntityID,
|
EntityID: pat.EntityID,
|
||||||
Quarantined: true,
|
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.
|
// wilsonLowerBound computes the Wilson score interval lower bound.
|
||||||
// Conservative estimate of success rate for small sample sizes.
|
// Conservative estimate of success rate for small sample sizes.
|
||||||
func wilsonLowerBound(success, total, z float64) float64 {
|
func wilsonLowerBound(success, total, z float64) float64 {
|
||||||
|
|||||||
211
internal/learning/learning_test.go
Normal file
211
internal/learning/learning_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
187
internal/notifier/notifier_test.go
Normal file
187
internal/notifier/notifier_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -92,21 +92,7 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine route
|
// Determine route
|
||||||
route := "escalate"
|
route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute blast radius
|
// Compute blast radius
|
||||||
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
||||||
@@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
|||||||
}, nil
|
}, 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.
|
// computeBlastRadius traverses relationships to find affected entities.
|
||||||
func computeBlastRadius(ctx context.Context, dbc interface {
|
func computeBlastRadius(ctx context.Context, dbc interface {
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
|||||||
62
internal/policy/classify_test.go
Normal file
62
internal/policy/classify_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
208
internal/scheduler/scheduler_test.go
Normal file
208
internal/scheduler/scheduler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low |
|
||||||
| R10 | Replace `<svelte:component>` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low |
|
| R10 | Replace `<svelte:component>` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low |
|
||||||
|
|||||||
Reference in New Issue
Block a user