- crows serve: HTTP API, Server-Sent Events per list, web approvals with a 10-minute fail-safe timeout; localhost only by default - Shared pipeline for command line and server; trust.changed events - EVENTS.md: server/UI contract; RIVE_CONTRACT.md: data-binding spec for the animator (one Crow artboard for v1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
223 lines
5.6 KiB
Go
223 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Trust levels.
|
|
const (
|
|
Stranger = 0 // you approve triage and output
|
|
Regular = 1 // output auto-accepted, marked unreviewed
|
|
Trusted = 2 // runs on its own
|
|
)
|
|
|
|
// Record is a track record for one holder (a crow, or the whole flock) on one task type.
|
|
type Record struct {
|
|
Approved int `json:"approved"`
|
|
Rejected int `json:"rejected"`
|
|
Streak int `json:"streak"`
|
|
Level int `json:"level"`
|
|
}
|
|
|
|
func (r *Record) total() int { return r.Approved + r.Rejected }
|
|
|
|
// apply: slow to earn, quick to lose.
|
|
func (r *Record) apply(ok bool, promoteAfter int) {
|
|
if ok {
|
|
r.Approved++
|
|
r.Streak++
|
|
if r.Streak >= promoteAfter && r.Level < Trusted {
|
|
r.Level++
|
|
r.Streak = 0
|
|
}
|
|
return
|
|
}
|
|
r.Rejected++
|
|
r.Streak = 0
|
|
if r.Level > Stranger {
|
|
r.Level--
|
|
}
|
|
}
|
|
|
|
// Requester trust: abuse earns a mild, fading penalty.
|
|
type Requester struct {
|
|
Strikes int `json:"strikes"`
|
|
LastStrike time.Time `json:"last_strike"`
|
|
CooldownUntil time.Time `json:"cooldown_until"`
|
|
Recent []time.Time `json:"recent"`
|
|
}
|
|
|
|
type Ledger struct {
|
|
Crows map[string]*Record `json:"crows"` // key: crow|task_type
|
|
Flock map[string]*Record `json:"flock"` // key: task_type
|
|
Requesters map[string]*Requester `json:"requesters"`
|
|
|
|
path string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func LoadLedger(path string) (*Ledger, error) {
|
|
l := &Ledger{path: path}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if err == nil {
|
|
if err := json.Unmarshal(data, l); err != nil {
|
|
return nil, fmt.Errorf("ledger is unreadable: %w", err)
|
|
}
|
|
}
|
|
if l.Crows == nil {
|
|
l.Crows = map[string]*Record{}
|
|
}
|
|
if l.Flock == nil {
|
|
l.Flock = map[string]*Record{}
|
|
}
|
|
if l.Requesters == nil {
|
|
l.Requesters = map[string]*Requester{}
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (l *Ledger) Save() error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
data, err := json.MarshalIndent(l, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := l.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, l.path)
|
|
}
|
|
|
|
func get(m map[string]*Record, k string) *Record {
|
|
r := m[k]
|
|
if r == nil {
|
|
r = &Record{}
|
|
m[k] = r
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Level blends a crow's own record with the flock's record for the task type.
|
|
// A new crow flies on the flock's reputation; its own record takes over as it grows.
|
|
func (l *Ledger) Level(crow, taskType string) int {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
own := get(l.Crows, crow+"|"+taskType)
|
|
flock := get(l.Flock, taskType)
|
|
w := float64(own.total()) / float64(own.total()+5)
|
|
return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level)))
|
|
}
|
|
|
|
// TrustChange is one level moving up or down. Holder is a crow name or "flock".
|
|
type TrustChange struct {
|
|
Holder, TaskType string
|
|
From, To int
|
|
}
|
|
|
|
// Judge records a verdict for every crow involved, and once for the flock.
|
|
// The flock needs twice the streak to level up. It reports any level changes.
|
|
func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) []TrustChange {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
var changes []TrustChange
|
|
judge := func(holder string, r *Record, need int) {
|
|
from := r.Level
|
|
r.apply(ok, need)
|
|
if r.Level != from {
|
|
changes = append(changes, TrustChange{Holder: holder, TaskType: taskType, From: from, To: r.Level})
|
|
}
|
|
}
|
|
for _, c := range crows {
|
|
judge(c, get(l.Crows, c+"|"+taskType), promoteAfter)
|
|
}
|
|
judge("flock", get(l.Flock, taskType), promoteAfter*2)
|
|
return changes
|
|
}
|
|
|
|
// MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying.
|
|
func (l *Ledger) MarshalSnapshot() ([]byte, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return json.MarshalIndent(l, "", " ")
|
|
}
|
|
|
|
func (l *Ledger) requester(id string) *Requester {
|
|
r := l.Requesters[id]
|
|
if r == nil {
|
|
r = &Requester{}
|
|
l.Requesters[id] = r
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Admit checks a requester at the door: cooldowns, rate cap, and fading grudges.
|
|
// When it says no, it returns a prank message and how long the cooldown lasts.
|
|
func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string, time.Duration) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
r := l.requester(id)
|
|
|
|
// Grudges fade: one strike forgiven per clean day.
|
|
if r.Strikes > 0 {
|
|
if days := int(now.Sub(r.LastStrike) / (24 * time.Hour)); days > 0 {
|
|
r.Strikes = max(0, r.Strikes-days)
|
|
r.LastStrike = r.LastStrike.Add(time.Duration(days) * 24 * time.Hour)
|
|
}
|
|
}
|
|
if now.Before(r.CooldownUntil) {
|
|
wait := r.CooldownUntil.Sub(now)
|
|
return false, prank(wait), wait
|
|
}
|
|
kept := r.Recent[:0]
|
|
for _, t := range r.Recent {
|
|
if now.Sub(t) < time.Minute {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
r.Recent = kept
|
|
if len(r.Recent) >= g.MaxRequestsPerMin {
|
|
wait := strike(r, now, g)
|
|
return false, prank(wait), wait
|
|
}
|
|
r.Recent = append(r.Recent, now)
|
|
return true, "", 0
|
|
}
|
|
|
|
func strike(r *Requester, now time.Time, g Guardrails) time.Duration {
|
|
r.Strikes++
|
|
r.LastStrike = now
|
|
cd := g.BaseCooldown
|
|
for i := 1; i < r.Strikes && cd < g.MaxCooldown; i++ {
|
|
cd *= 2
|
|
}
|
|
cd = min(cd, g.MaxCooldown)
|
|
r.CooldownUntil = now.Add(cd)
|
|
return cd
|
|
}
|
|
|
|
var pranks = []string{
|
|
"A crow has left a small gift on your windshield. The flock will hear you again in %s.",
|
|
"The flock turns its back on you. Try again in %s.",
|
|
"Something shiny of yours has gone missing. Come back in %s.",
|
|
"Caw. (Translation: not for another %s.)",
|
|
}
|
|
|
|
func prank(d time.Duration) string {
|
|
return fmt.Sprintf(pranks[rand.IntN(len(pranks))], d.Round(time.Second))
|
|
}
|