- 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
98 lines
2.1 KiB
Go
98 lines
2.1 KiB
Go
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
|
|
}
|