Add web server, event stream, and Rive contract
- 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
This commit is contained in:
12 files changed
+1320
-129
No files matched your search
+97
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StdinApprover asks at the terminal. It fails safe: anything but y/yes,
|
||||
// including no input at all, is a no.
|
||||
type StdinApprover struct {
|
||||
mu sync.Mutex
|
||||
in *bufio.Reader
|
||||
}
|
||||
|
||||
func (s *StdinApprover) Approve(ctx context.Context, a Approval) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prompt := " Accept this answer?"
|
||||
if a.Target == "triage" {
|
||||
prompt = " Approve triage?"
|
||||
}
|
||||
if confirm(s.in, prompt) {
|
||||
return "approved"
|
||||
}
|
||||
return "rejected"
|
||||
}
|
||||
|
||||
// confirm fails safe: anything but y/yes, including no input at all, is a no.
|
||||
func confirm(in *bufio.Reader, prompt string) bool {
|
||||
fmt.Print(prompt + " [y/N] ")
|
||||
line, _ := in.ReadString('\n')
|
||||
a := strings.ToLower(strings.TrimSpace(line))
|
||||
if line == "" {
|
||||
fmt.Println()
|
||||
}
|
||||
return a == "y" || a == "yes"
|
||||
}
|
||||
|
||||
// WebApprover blocks an item's goroutine until POST /api/verdicts answers it,
|
||||
// or the timeout passes, which counts as a rejection.
|
||||
type WebApprover struct {
|
||||
Timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan bool // key: item_id|target
|
||||
}
|
||||
|
||||
func NewWebApprover(timeout time.Duration) *WebApprover {
|
||||
return &WebApprover{Timeout: timeout, pending: map[string]chan bool{}}
|
||||
}
|
||||
|
||||
func approvalKey(itemID, target string) string { return itemID + "|" + target }
|
||||
|
||||
func (w *WebApprover) Approve(ctx context.Context, a Approval) string {
|
||||
key := approvalKey(a.ItemID, a.Target)
|
||||
ch := make(chan bool, 1)
|
||||
w.mu.Lock()
|
||||
w.pending[key] = ch
|
||||
w.mu.Unlock()
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
delete(w.pending, key)
|
||||
w.mu.Unlock()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(w.Timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case ok := <-ch:
|
||||
if ok {
|
||||
return "approved"
|
||||
}
|
||||
return "rejected"
|
||||
case <-timer.C:
|
||||
return "timeout"
|
||||
case <-ctx.Done():
|
||||
return "rejected"
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve delivers a human's verdict. It reports false if nothing is waiting.
|
||||
func (w *WebApprover) Resolve(itemID, target string, approve bool) bool {
|
||||
key := approvalKey(itemID, target)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
ch, ok := w.pending[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(w.pending, key) // one verdict per question
|
||||
ch <- approve
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user