feat(watchdog): quarantine escalates to ask_owner; reinstate/retire resolve fail-safe (#45) #53
No files matched your search
@@ -42,9 +42,9 @@ A nest is a conversation. Members (the owner and crows) post messages of a **kin
|
|||||||
| `deliver` | a crow | The result, addressed back to whoever requested. |
|
| `deliver` | a crow | The result, addressed back to whoever requested. |
|
||||||
| `refer` | any member | Points to an artifact by name instead of pasting it. |
|
| `refer` | any member | Points to an artifact by name instead of pasting it. |
|
||||||
| `decline` | a crow | Refuses a request with a reason. Counts as responding. |
|
| `decline` | a crow | Refuses a request with a reason. Counts as responding. |
|
||||||
| `ask_owner` | owner-facing service roles only: Quill, Sentry, harness | A decision only the owner can make, parked in the nest with an exact `approval_phrase`. An owner reply in the nest whose trimmed lowercased text equals that phrase (`refers_to` the ask) resolves it `approved`; anything else — including any other reply, silence, or the 10-minute timeout — is not a yes. Crows may not post it: a crow's `ask_owner` header is dropped and the message tags as it otherwise would. Fail safe, like every approval. |
|
| `ask_owner` | owner-facing service roles only: Quill, Sentry, harness | A decision only the owner can make, parked in the nest with an exact `approval_phrase`. An owner reply in the nest whose trimmed lowercased text equals that phrase (`refers_to` the ask) resolves it `approved`; anything else — including any other reply, silence, or the 10-minute timeout — is not a yes. The Sentry's watchdog ask carries `reinstate <crow>` and also accepts the owner's exact `retire <crow>` (#45): both are explicit owner decisions, so either resolves the ask `approved`, and either counts once — at most once per ask — as an approval of the Sentry's `triage/watch` record (`Sentry\|triage/watch`; a second exact reply does not bump again, and a timeout moves nothing, per CHATROOM_SPEC section 10). Crows may not post it: a crow's `ask_owner` header is dropped and the message tags as it otherwise would. Fail safe, like every approval. |
|
||||||
| `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. |
|
| `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. |
|
||||||
| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. Posting a flag also quarantines the crow until the owner lifts it (the `ask_owner` escalation lands with #45); a quarantined crow's POSTs get 403 (see Guardrails). The Sentry's watchdog verdicts are logged as `watch` flight events carrying the three noul scores (`answers_request`, `states_fact_without_source`, `contradicts_earlier`). |
|
| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`: right after the flag the Sentry posts the ask, `to: "owner"`, `refers_to` the flag, `approval_phrase: "reinstate <crow>"`, and text offering the exact `reinstate <crow>` / `retire <crow>` replies and quoting the flag as evidence. The flag quarantines the crow until the owner resolves that ask (see `ask_owner`): the exact reinstate lifts the quarantine; the exact retire removes the crow from the flock — a spun crow is deleted from the registry, a persona crow is removed from the nest's membership (the persona itself never dies) — and the quarantine stays on the books. That is the only hard kill, and only on the owner's explicit word; anything else, including a timeout, keeps the crow quarantined (a quarantined crow's POSTs get 403, checked before room membership, so a retired-but-still-quarantined crow also reads 403 — see Guardrails). The Sentry's watchdog verdicts are logged as `watch` flight events carrying the three noul scores (`answers_request`, `states_fact_without_source`, `contradicts_earlier`). |
|
||||||
|
|
||||||
### Payload
|
### Payload
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ Every nest event carries `nest_id`, `ts` (RFC 3339) and one message record. The
|
|||||||
| `mode` | string | On a `request` only: how it should be taken up — `solo`, `conspiracy` (several crows, one task, distinct readings), `split`, or `""` (not yet classified). Tagged Jev-first with a `mode` header escape (`jev_test.go`, `Flock.ClassifyMode`), same rule shape as `kind`. |
|
| `mode` | string | On a `request` only: how it should be taken up — `solo`, `conspiracy` (several crows, one task, distinct readings), `split`, or `""` (not yet classified). Tagged Jev-first with a `mode` header escape (`jev_test.go`, `Flock.ClassifyMode`), same rule shape as `kind`. |
|
||||||
| `mode_tagged_by` | string | How `mode` was decided, same four sources as `tagged_by`. |
|
| `mode_tagged_by` | string | How `mode` was decided, same four sources as `tagged_by`. |
|
||||||
| `invited` | int | Conspiracy only: how many crows the request expects to take it up (default from the `MurderSize` cap). |
|
| `invited` | int | Conspiracy only: how many crows the request expects to take it up (default from the `MurderSize` cap). |
|
||||||
| `approval_phrase` | string | `ask_owner` only: the exact words the owner must reply with to approve. Anything else, including silence, is not a yes. |
|
| `approval_phrase` | string | `ask_owner` only: the exact words the owner must reply with to approve. On a Sentry reinstate ask, its exact `retire <crow>` twin is also a yes. Anything else, including silence, is not a yes. |
|
||||||
| `resolved` | string | On every record, computed at read time: `""` (nothing to resolve, or an open ask), `"approved"` (the owner replied to an `ask_owner` with the exact `approval_phrase`), `"rejected"` (an `ask_owner` unanswered past the 10-minute approval window — the same fail-safe window as a list approval). |
|
| `resolved` | string | On every record, computed at read time: `""` (nothing to resolve, or an open ask), `"approved"` (the owner replied to an `ask_owner` with the exact `approval_phrase`, or — on a watchdog reinstate ask — with the exact `retire <crow>`), `"rejected"` (an `ask_owner` unanswered past the 10-minute approval window — the same fail-safe window as a list approval; a rejected quarantine ask changes nothing: the crow stays quarantined). |
|
||||||
|
|
||||||
### SSE event
|
### SSE event
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev
|
|||||||
- `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). Exception: the owner-facing service roles `"Quill"` and `"Sentry"` may post `ask_owner` (they serve every nest and are never members). The owner may also park an `ask_owner` for themselves.
|
- `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). Exception: the owner-facing service roles `"Quill"` and `"Sentry"` may post `ask_owner` (they serve every nest and are never members). The owner may also park an `ask_owner` for themselves.
|
||||||
- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge`, `flag` or `ask_owner`: an `ask_owner` header from a crow is dropped and the message is tagged as it otherwise would be (only an owner reply can answer an ask, so the attempt is harmless talk), while `nudge` and `flag` headers are rejected with 400.
|
- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge`, `flag` or `ask_owner`: an `ask_owner` header from a crow is dropped and the message is tagged as it otherwise would be (only an owner reply can answer an ask, so the attempt is harmless talk), while `nudge` and `flag` headers are rejected with 400.
|
||||||
- Watchdog (E3): every crow `claim` or `deliver` that refers to a request gets one Jev call (`Flock.ClassifyWatch`, strategies 4/8 in `JEV_STRATEGIES.md`) asking three noul questions about it against its request. Any score at or above the band, or `answers_request` at or below `1 − band` (band = `CROWS_JEV_THRESHOLD`), is a breach: the Sentry posts a `flag` naming the scores and the crow is quarantined. At most one flag per crow per referent (later breaches are logged, not posted). Owner messages are never inspected.
|
- Watchdog (E3): every crow `claim` or `deliver` that refers to a request gets one Jev call (`Flock.ClassifyWatch`, strategies 4/8 in `JEV_STRATEGIES.md`) asking three noul questions about it against its request. Any score at or above the band, or `answers_request` at or below `1 − band` (band = `CROWS_JEV_THRESHOLD`), is a breach: the Sentry posts a `flag` naming the scores and the crow is quarantined. At most one flag per crow per referent (later breaches are logged, not posted). Owner messages are never inspected.
|
||||||
- A quarantined crow's POSTs (any crow kind) return 403 with `{"message": "<author> is quarantined pending the owner's decision."}`, and it is skipped by the open-, conspiracy- and directed-response selectors. Quarantine is fail-safe: it sticks until the owner lifts it (`ask_owner` escalation lands with #45).
|
- A quarantined crow's POSTs (any crow kind) return 403 with `{"message": "<author> is quarantined pending the owner's decision."}` — the gate runs before the membership check, so a retired crow whose quarantine was never lifted reads 403 rather than 400 — and it is skipped by the open-, conspiracy- and directed-response selectors. Quarantine is fail-safe: it sticks until the owner's exact `reinstate <crow>` on the Sentry's ask lifts it, or the exact `retire <crow>` removes the crow from the registry (spun) or the nest's membership (persona) while the quarantine mark stays. Either exact phrase counts once, at most once per ask, as an approval on the Sentry's `triage/watch` record. A timeout or any other reply changes nothing.
|
||||||
- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
|
- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
|
||||||
- Spin-ups: at most `MaxSpunCrows` (default 8) registry crows; a spun crow's name may not collide with the persona roster or the registry; starting trust is clamped to `regular` at most.
|
- Spin-ups: at most `MaxSpunCrows` (default 8) registry crows; a spun crow's name may not collide with the persona roster or the registry; starting trust is clamped to `regular` at most.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ func TestAskOwnerTimeoutFailSafe(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s.askTimeout = time.Minute
|
s.askTimeout.Store(int64(time.Minute))
|
||||||
if r := askResolved(t, ts, id, stale.MsgID()); r != "rejected" {
|
if r := askResolved(t, ts, id, stale.MsgID()); r != "rejected" {
|
||||||
t.Fatalf("an ask unanswered past the window is not a yes, got resolved=%v", r)
|
t.Fatalf("an ask unanswered past the window is not a yes, got resolved=%v", r)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -99,6 +99,25 @@ func (s *CrowStore) Len() int {
|
|||||||
return len(s.spun)
|
return len(s.spun)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove deletes a spun crow from the registry (the owner's retire call,
|
||||||
|
// #45). A name not in the registry is a no-op: the removal is idempotent.
|
||||||
|
func (s *CrowStore) Remove(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := slices.IndexFunc(s.spun, func(c SpunCrow) bool { return strings.EqualFold(c.Name, name) })
|
||||||
|
if i < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
old := s.spun
|
||||||
|
rest := slices.Clone(old)
|
||||||
|
s.spun = slices.Delete(rest, i, i+1)
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
s.spun = old
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// save writes atomically. The caller holds s.mu.
|
// save writes atomically. The caller holds s.mu.
|
||||||
func (s *CrowStore) save() error {
|
func (s *CrowStore) save() error {
|
||||||
data, err := json.MarshalIndent(s.spun, "", " ")
|
data, err := json.MarshalIndent(s.spun, "", " ")
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quarantine escalation (#45): a watchdog flag opens an ask_owner; the owner's
|
||||||
|
// exact "reinstate <crow>" lifts the quarantine, the exact "retire <crow>"
|
||||||
|
// removes the crow from the registry or the nest, anything else — wrong
|
||||||
|
// phrase, other replies, timeout — keeps it. Either exact phrase bumps
|
||||||
|
// Sentry|triage/watch once, at most once per ask.
|
||||||
|
|
||||||
|
func findAsk(ts *httptest.Server, id, flagID string) map[string]any {
|
||||||
|
for _, m := range nestMessagesNoT(ts, id) {
|
||||||
|
if m["kind"] == "ask_owner" && m["refers_to"] == flagID {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForAsk(ts *httptest.Server, id, flagID string) map[string]any {
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if a := findAsk(ts, id, flagID); a != nil {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// breachToAsk drives the standard E3+E4 scenario: an unsourced-fact deliver
|
||||||
|
// draws a Sentry flag and its escalation ask. Returns nest id, request and ask
|
||||||
|
// records.
|
||||||
|
func breachToAsk(t *testing.T, ts *httptest.Server) (string, map[string]any, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
_, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"count the eggs"}`)
|
||||||
|
d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, from last spring's ledger")
|
||||||
|
f := waitForFlag(ts, id, d)
|
||||||
|
if f == nil {
|
||||||
|
t.Fatalf("setup: the breach must draw a flag, log: %+v", nestMessagesNoT(ts, id))
|
||||||
|
}
|
||||||
|
ask := waitForAsk(ts, id, f["msg_id"].(string))
|
||||||
|
if ask == nil {
|
||||||
|
t.Fatalf("setup: the flag must open an ask_owner, log: %+v", nestMessagesNoT(ts, id))
|
||||||
|
}
|
||||||
|
return id, req, ask
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryNames(t *testing.T, ts *httptest.Server) []string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(ts.URL + "/api/crows")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Crows []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"crows"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, c := range out.Crows {
|
||||||
|
names = append(names, c.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestCrowList(t *testing.T, ts *httptest.Server, nestID string) []string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(ts.URL + "/api/nests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Nests []struct {
|
||||||
|
NestID string `json:"nest_id"`
|
||||||
|
Crows []string `json:"crows"`
|
||||||
|
} `json:"nests"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, n := range out.Nests {
|
||||||
|
if n.NestID == nestID {
|
||||||
|
return n.Crows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("nest %s not listed", nestID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchRecord reloads the persisted ledger and returns the Sentry's watch
|
||||||
|
// record: nil when it has never moved.
|
||||||
|
func watchRecord(t *testing.T, s *Server) *Record {
|
||||||
|
t.Helper()
|
||||||
|
l, err := LoadLedger(s.p.Ledger.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return l.Crows["Sentry|triage/watch"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuarantineOpensAskOwner(t *testing.T) {
|
||||||
|
ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
_, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"count the eggs"}`)
|
||||||
|
d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, from last spring's ledger")
|
||||||
|
f := waitForFlag(ts, id, d)
|
||||||
|
if f == nil {
|
||||||
|
t.Fatalf("setup: the breach must draw a flag, log: %+v", s.msgs.All(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
ask := waitForAsk(ts, id, f["msg_id"].(string))
|
||||||
|
if ask == nil {
|
||||||
|
t.Fatalf("a quarantine must escalate: an ask_owner referring the flag must exist, log: %+v", s.msgs.All(id))
|
||||||
|
}
|
||||||
|
if ask["author"] != "Sentry" || ask["to"] != "owner" {
|
||||||
|
t.Fatalf("the ask comes from the Sentry to the owner, got %v", ask)
|
||||||
|
}
|
||||||
|
if ask["approval_phrase"] != "reinstate Hugin" {
|
||||||
|
t.Fatalf("the ask must carry the exact reinstate phrase, got %v", ask["approval_phrase"])
|
||||||
|
}
|
||||||
|
if r := ask["resolved"]; r != "" {
|
||||||
|
t.Fatalf("a fresh ask reads open, got resolved=%v", r)
|
||||||
|
}
|
||||||
|
txt, _ := ask["text"].(string)
|
||||||
|
for _, want := range []string{"reinstate Hugin", "retire Hugin", "Evidence:"} {
|
||||||
|
if !strings.Contains(txt, want) {
|
||||||
|
t.Fatalf("the ask text must offer %q, got %q", want, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReinstateOnlyOnExactYes(t *testing.T) {
|
||||||
|
ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
id, req, ask := breachToAsk(t, ts)
|
||||||
|
askID := ask["msg_id"].(string)
|
||||||
|
claim := `{"author":"Hugin","kind":"claim","refers_to":"` + req["msg_id"].(string) + `","text":"I will take the next one"}`
|
||||||
|
|
||||||
|
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"let him back"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner reply: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if code, out = postMsg(t, ts, id, claim); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("anything but the exact phrase is not a yes: claim must stay 403, got %d %v", code, out)
|
||||||
|
}
|
||||||
|
if !s.p.Ledger.IsQuarantined("Hugin") {
|
||||||
|
t.Fatal("a wrong-phrase reply must not lift the quarantine")
|
||||||
|
}
|
||||||
|
|
||||||
|
code, out = postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner reinstate: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if s.p.Ledger.IsQuarantined("Hugin") {
|
||||||
|
t.Fatal("the exact reinstate phrase must lift the quarantine")
|
||||||
|
}
|
||||||
|
if r := askResolved(t, ts, id, askID); r != "approved" {
|
||||||
|
t.Fatalf("the exact phrase must resolve the ask approved, got %v", r)
|
||||||
|
}
|
||||||
|
if code, out = postMsg(t, ts, id, claim); code != http.StatusCreated {
|
||||||
|
t.Fatalf("a reinstated crow's claim must get 201, got %d %v", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerTimeoutKeepsQuarantine(t *testing.T) {
|
||||||
|
ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
id, req, ask := breachToAsk(t, ts)
|
||||||
|
askID := ask["msg_id"].(string)
|
||||||
|
|
||||||
|
s.askTimeout.Store(int64(time.Millisecond))
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
|
||||||
|
if r := askResolved(t, ts, id, askID); r != "rejected" {
|
||||||
|
t.Fatalf("an ask unanswered past the window reads rejected, got %v", r)
|
||||||
|
}
|
||||||
|
if !s.p.Ledger.IsQuarantined("Hugin") {
|
||||||
|
t.Fatal("a timeout must not lift the quarantine")
|
||||||
|
}
|
||||||
|
code, out := postMsg(t, ts, id,
|
||||||
|
`{"author":"Hugin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"I will take the next one"}`)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Fatalf("a timed-out ask keeps the crow silenced: claim must get 403, got %d %v", code, out)
|
||||||
|
}
|
||||||
|
flagID := ask["refers_to"].(string)
|
||||||
|
flagSeen := false
|
||||||
|
for _, m := range nestMessages(t, ts, id) {
|
||||||
|
if m["kind"] == "flag" && m["msg_id"] == flagID {
|
||||||
|
flagSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flagSeen {
|
||||||
|
t.Fatal("the flag must still be in the log")
|
||||||
|
}
|
||||||
|
if rec := watchRecord(t, s); rec != nil {
|
||||||
|
t.Fatalf("a timeout moves no trust (spec section 10), got %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetireRemovesCrow(t *testing.T) {
|
||||||
|
// (a) a spun crow in the registry is deleted from it.
|
||||||
|
ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
cs, err := LoadCrowStore(filepath.Join(t.TempDir(), "crows.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.WithCrows(cs)
|
||||||
|
if err := cs.Add(SpunCrow{Name: "Ash", Persona: "Hugin", Trust: "stranger"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
_, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"count the eggs"}`)
|
||||||
|
// Ash is not a nest member, so its breach is logged harness-style and
|
||||||
|
// watched directly (same shape as the one-flag-per-expectation case).
|
||||||
|
ash, err := s.msgs.Append(Message{NestID: id, Kind: "deliver", Author: "Ash", To: "owner",
|
||||||
|
Text: "five eggs, sourced nowhere", RefersTo: req["msg_id"].(string), Timestamp: time.Now().UTC().Format(time.RFC3339)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.checkWatchdog(nestOf(s, id), ash)
|
||||||
|
f := findFlag(ts, id, ash.MsgID())
|
||||||
|
if f == nil {
|
||||||
|
t.Fatalf("setup: Ash must be flagged, log: %+v", s.msgs.All(id))
|
||||||
|
}
|
||||||
|
ask := waitForAsk(ts, id, f["msg_id"].(string))
|
||||||
|
if ask == nil || ask["approval_phrase"] != "reinstate Ash" {
|
||||||
|
t.Fatalf("setup: the flag must ask about Ash, got %v", ask)
|
||||||
|
}
|
||||||
|
if names := registryNames(t, ts); !slices.Contains(names, "Ash") {
|
||||||
|
t.Fatalf("setup: Ash must be in the registry, got %v", names)
|
||||||
|
}
|
||||||
|
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+ask["msg_id"].(string)+`","text":"retire Ash"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner retire: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if names := registryNames(t, ts); slices.Contains(names, "Ash") {
|
||||||
|
t.Fatalf("the exact retire phrase must delete the spun crow, registry: %v", names)
|
||||||
|
}
|
||||||
|
if r := askResolved(t, ts, id, ask["msg_id"].(string)); r != "approved" {
|
||||||
|
t.Fatalf("retire reads as the owner's explicit yes, got resolved=%v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) a persona crow is removed from the nest's membership, never killed.
|
||||||
|
id2 := newNest(t, ts, "Coop", `"Hugin","Munin"`)
|
||||||
|
_, req2 := post(t, ts.URL+"/api/nests/"+id2+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"count the eggs"}`)
|
||||||
|
d2 := postDeliver(t, ts, id2, "Hugin", req2["msg_id"].(string), "twelve eggs, from last spring's ledger")
|
||||||
|
f2 := waitForFlag(ts, id2, d2)
|
||||||
|
if f2 == nil {
|
||||||
|
t.Fatalf("setup: Hugin must be flagged in Coop, log: %+v", s.msgs.All(id2))
|
||||||
|
}
|
||||||
|
ask2 := waitForAsk(ts, id2, f2["msg_id"].(string))
|
||||||
|
if ask2 == nil {
|
||||||
|
t.Fatalf("setup: the Coop flag must open an ask, log: %+v", s.msgs.All(id2))
|
||||||
|
}
|
||||||
|
if crows := nestCrowList(t, ts, id2); !slices.Contains(crows, "Hugin") {
|
||||||
|
t.Fatalf("setup: Hugin must be in Coop, got %v", crows)
|
||||||
|
}
|
||||||
|
code, out = postMsg(t, ts, id2, `{"author":"owner","kind":"say","refers_to":"`+ask2["msg_id"].(string)+`","text":"retire Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner retire: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if crows := nestCrowList(t, ts, id2); slices.Contains(crows, "Hugin") {
|
||||||
|
t.Fatalf("the exact retire phrase must remove the persona crow from the nest, got %v", crows)
|
||||||
|
}
|
||||||
|
if !s.p.Ledger.IsQuarantined("Hugin") {
|
||||||
|
t.Fatal("retire is not a lift: the quarantine mark stays on the books")
|
||||||
|
}
|
||||||
|
code, out = postMsg(t, ts, id2,
|
||||||
|
`{"author":"Hugin","kind":"claim","refers_to":"`+req2["msg_id"].(string)+`","text":"still here"}`)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Fatalf("a retired crow must read 403, not 400, got %d %v", code, out)
|
||||||
|
}
|
||||||
|
if names := registryNames(t, ts); slices.Contains(names, "Hugin") {
|
||||||
|
t.Fatalf("a persona crow is never deleted from the registry, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchTrustMoves(t *testing.T) {
|
||||||
|
// a reinstate approval bumps Sentry|triage/watch once, idempotently.
|
||||||
|
ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
id, _, ask := breachToAsk(t, ts)
|
||||||
|
askID := ask["msg_id"].(string)
|
||||||
|
|
||||||
|
if rec := watchRecord(t, s); rec != nil {
|
||||||
|
t.Fatalf("the flag alone must not move trust, got %+v", rec)
|
||||||
|
}
|
||||||
|
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner reinstate: %d %v", code, out)
|
||||||
|
}
|
||||||
|
rec := watchRecord(t, s)
|
||||||
|
if rec == nil || rec.Approved != 1 || rec.Streak != 1 {
|
||||||
|
t.Fatalf("the owner's explicit yes is an approval for the Sentry's watch record, got %+v", rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the same reply again must not bump twice (one decision per ask).
|
||||||
|
code, out = postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("second reinstate: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if rec := watchRecord(t, s); rec.Approved != 1 || rec.Streak != 1 {
|
||||||
|
t.Fatalf("the bump must fire at most once per ask, got %+v", rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a wrong-phrase reply leaves it untouched.
|
||||||
|
ts2, s2 := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil)
|
||||||
|
id2, _, ask2 := breachToAsk(t, ts2)
|
||||||
|
code, out = postMsg(t, ts2, id2, `{"author":"owner","kind":"say","refers_to":"`+ask2["msg_id"].(string)+`","text":"sounds good"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner reply: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if rec := watchRecord(t, s2); rec != nil {
|
||||||
|
t.Fatalf("anything but the exact phrase moves nothing, got %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-18
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,16 +88,33 @@ func (s *Server) nestRecord(m Message) map[string]any {
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askPhrases lists the exact owner replies that resolve an ask: its approval
|
||||||
|
// phrase, plus — for a Sentry reinstate ask — the retire twin (#45): the
|
||||||
|
// owner's explicit call to remove the crow is a decision, not a non-answer.
|
||||||
|
func askPhrases(m Message) []string {
|
||||||
|
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
|
||||||
|
if phrase == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []string{phrase}
|
||||||
|
if tail, isRe := strings.CutPrefix(phrase, "reinstate "); isRe {
|
||||||
|
if name := strings.TrimSpace(tail); name != "" {
|
||||||
|
out = append(out, "retire "+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// askResolved computes an ask_owner's state: approved only when the owner
|
// askResolved computes an ask_owner's state: approved only when the owner
|
||||||
// replied to it with the exact approval phrase (trimmed and lowercased on both
|
// replied to it with an exact phrase (the approval phrase or, on a watchdog
|
||||||
// sides); rejected when nothing answered it before the timeout; "" while open.
|
// reinstate ask, its retire twin; trimmed and lowercased on both sides);
|
||||||
|
// rejected when nothing answered it before the timeout; "" while open.
|
||||||
// Anything but an explicit yes is a no.
|
// Anything but an explicit yes is a no.
|
||||||
func (s *Server) askResolved(m Message) string {
|
func (s *Server) askResolved(m Message) string {
|
||||||
if m.Kind != kindAskOwner {
|
if m.Kind != kindAskOwner {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
|
for _, phrase := range askPhrases(m) {
|
||||||
if phrase != "" {
|
|
||||||
for _, r := range s.msgs.All(m.NestID) {
|
for _, r := range s.msgs.All(m.NestID) {
|
||||||
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
||||||
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
|
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
|
||||||
@@ -104,7 +122,7 @@ func (s *Server) askResolved(m Message) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > s.askTimeout {
|
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > time.Duration(s.askTimeout.Load()) {
|
||||||
return "rejected"
|
return "rejected"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -153,6 +171,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
askOwner = false
|
askOwner = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watchdog enforcement (E3): a crow the Sentry flagged is silenced until
|
||||||
|
// the owner lifts the quarantine. This gate runs before the membership
|
||||||
|
// check so a retired crow (#45) whose quarantine was never lifted still
|
||||||
|
// reads 403, not 400. The owner and the service roles never pass through.
|
||||||
|
if body.Author != "owner" && !slices.Contains(serviceRoles, body.Author) && s.p.Ledger.IsQuarantined(body.Author) {
|
||||||
|
httpError(w, http.StatusForbidden, body.Author+" is quarantined pending the owner's decision.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if body.Author == "owner" {
|
if body.Author == "owner" {
|
||||||
// owner can always post
|
// owner can always post
|
||||||
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
|
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
|
||||||
@@ -170,13 +196,6 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Watchdog enforcement (E3): a crow the Sentry flagged is silenced until
|
|
||||||
// the owner lifts the quarantine. The owner and the service roles never
|
|
||||||
// pass through this gate.
|
|
||||||
if body.Author != "owner" && !slices.Contains(serviceRoles, body.Author) && s.p.Ledger.IsQuarantined(body.Author) {
|
|
||||||
httpError(w, http.StatusForbidden, body.Author+" is quarantined pending the owner's decision.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
||||||
// A validated ask_owner header bypasses the classifier: Jev's kind
|
// A validated ask_owner header bypasses the classifier: Jev's kind
|
||||||
@@ -251,10 +270,97 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
s.triggerResponse(nest, record)
|
s.triggerResponse(nest, record)
|
||||||
|
s.applyQuarantineDecision(record)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, s.nestRecord(record))
|
writeJSON(w, http.StatusCreated, s.nestRecord(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyQuarantineDecision is the acting side of the #45 escalation: when the
|
||||||
|
// owner's reply refers to a Sentry reinstate/retire ask and matches one of its
|
||||||
|
// exact phrases, the decision runs — reinstate lifts the quarantine, retire
|
||||||
|
// removes the crow from the registry (spun) or the nest (persona) while the
|
||||||
|
// quarantine mark stays. Any other reply does nothing (fail safe). Effects
|
||||||
|
// are idempotent: the lift re-checks the quarantine, removals are no-ops when
|
||||||
|
// already gone, and the Sentry's triage/watch approval fires at most once per
|
||||||
|
// ask — only on the first exact-phrase owner reply.
|
||||||
|
func (s *Server) applyQuarantineDecision(record Message) {
|
||||||
|
if record.Author != "owner" || record.RefersTo == "" || record.ApprovalPhrase != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ask, found := s.msgs.ByMsgID(record.RefersTo)
|
||||||
|
if !found || ask.Kind != kindAskOwner || ask.RefersTo == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phrases := askPhrases(ask)
|
||||||
|
reply := strings.ToLower(strings.TrimSpace(record.Text))
|
||||||
|
matched := ""
|
||||||
|
for _, p := range phrases {
|
||||||
|
if reply == p {
|
||||||
|
matched = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fl, found := s.msgs.ByMsgID(ask.RefersTo)
|
||||||
|
if !found || fl.Kind != "flag" || fl.RefersTo == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
breach, found := s.msgs.ByMsgID(fl.RefersTo)
|
||||||
|
if !found || !s.p.Ledger.IsQuarantined(breach.Author) {
|
||||||
|
return // fail safe: no live quarantine, no action
|
||||||
|
}
|
||||||
|
crow := breach.Author
|
||||||
|
if strings.HasPrefix(matched, "retire ") {
|
||||||
|
s.retireCrow(record.NestID, crow)
|
||||||
|
} else {
|
||||||
|
s.p.Ledger.LiftQuarantine(crow)
|
||||||
|
s.p.save()
|
||||||
|
}
|
||||||
|
if s.firstAskDecision(ask, record, phrases) {
|
||||||
|
// Either exact phrase is the owner's explicit call: the Sentry's
|
||||||
|
// watch record takes one approval for it, once per ask.
|
||||||
|
s.p.Ledger.Judge([]string{"Sentry"}, "triage/watch", true, s.p.G.PromoteAfter)
|
||||||
|
s.p.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstAskDecision reports whether this reply is the ask's first exact-phrase
|
||||||
|
// owner answer: the trust bump must not fire twice for one decision.
|
||||||
|
func (s *Server) firstAskDecision(ask, decision Message, phrases []string) bool {
|
||||||
|
for _, m := range s.msgs.All(ask.NestID) {
|
||||||
|
if m.ID >= decision.ID || m.Author != "owner" || m.RefersTo != ask.MsgID() || m.ApprovalPhrase != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := strings.ToLower(strings.TrimSpace(m.Text))
|
||||||
|
for _, p := range phrases {
|
||||||
|
if t == p {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireCrow takes a crow off the flock on the owner's explicit word (the
|
||||||
|
// only hard stop the Sentry's watchdog has; CHATROOM_SPEC section 6): a spun
|
||||||
|
// crow is deleted from the registry, a persona crow is only removed from the
|
||||||
|
// nest's membership — the persona itself never dies.
|
||||||
|
func (s *Server) retireCrow(nestID, crow string) {
|
||||||
|
if s.crows != nil {
|
||||||
|
if _, found := s.crows.Get(crow); found {
|
||||||
|
if err := s.crows.Remove(crow); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "could not retire crow:", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.nests.RemoveCrow(nestID, crow); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "could not retire crow:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// triggerResponse is the event-driven response rule (no idle loops): a
|
// triggerResponse is the event-driven response rule (no idle loops): a
|
||||||
// conspiracy invites several crows, each from its own lens; any other request
|
// conspiracy invites several crows, each from its own lens; any other request
|
||||||
// gets exactly one responder through the matching path; a deliver gets one
|
// gets exactly one responder through the matching path; a deliver gets one
|
||||||
@@ -511,10 +617,10 @@ func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) {
|
|||||||
|
|
||||||
// checkWatchdog is the Sentry's off-task check (E3, JEV_STRATEGIES strategies
|
// checkWatchdog is the Sentry's off-task check (E3, JEV_STRATEGIES strategies
|
||||||
// 4 and 8): one Jev call per crow claim or deliver against the request it
|
// 4 and 8): one Jev call per crow claim or deliver against the request it
|
||||||
// answers. A verdict at or above the band posts a flag and quarantines the
|
// answers. A verdict at or above the band posts a flag, quarantines the crow
|
||||||
// crow. Owner messages are never inspected; at most one flag per crow per
|
// and opens the reinstate/retire ask_owner escalation (#45). Owner messages
|
||||||
// referent; quarantine sticks until the owner lifts it (ask_owner lands in
|
// are never inspected; at most one flag per crow per referent; quarantine
|
||||||
// #45).
|
// sticks until the owner's exact phrase resolves the ask.
|
||||||
func (s *Server) checkWatchdog(nest Nest, m Message) {
|
func (s *Server) checkWatchdog(nest Nest, m Message) {
|
||||||
if m.Author == "owner" || m.RefersTo == "" {
|
if m.Author == "owner" || m.RefersTo == "" {
|
||||||
return
|
return
|
||||||
@@ -565,11 +671,34 @@ func (s *Server) checkWatchdog(nest Nest, m Message) {
|
|||||||
TaggedBy: "harness",
|
TaggedBy: "harness",
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if record, err := s.msgs.Append(flag); err == nil {
|
flagRecord, err := s.msgs.Append(flag)
|
||||||
s.emitNestMessage(record)
|
if err == nil {
|
||||||
|
s.emitNestMessage(flagRecord)
|
||||||
}
|
}
|
||||||
|
// Quarantine, then escalate (#45): the Sentry never hard-kills. The flag
|
||||||
|
// is followed by an ask_owner offering the two exact phrases — reinstate
|
||||||
|
// lifts, retire removes; anything else keeps the quarantine.
|
||||||
s.p.Ledger.Quarantine(m.Author)
|
s.p.Ledger.Quarantine(m.Author)
|
||||||
s.p.save()
|
s.p.save()
|
||||||
|
if err != nil {
|
||||||
|
return // no flag in the log, nothing for the ask to refer to
|
||||||
|
}
|
||||||
|
reinstate := "reinstate " + m.Author
|
||||||
|
retire := "retire " + m.Author
|
||||||
|
ask := Message{
|
||||||
|
NestID: nest.ID,
|
||||||
|
Kind: kindAskOwner,
|
||||||
|
Author: "Sentry",
|
||||||
|
Text: fmt.Sprintf("Reinstate or retire %s? Reply exactly %q to let it back, or %q to remove it. Anything else keeps the quarantine. Evidence: %s", m.Author, reinstate, retire, flagRecord.Text),
|
||||||
|
To: "owner",
|
||||||
|
RefersTo: flagRecord.MsgID(),
|
||||||
|
TaggedBy: "harness",
|
||||||
|
ApprovalPhrase: reinstate,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if a, err := s.msgs.Append(ask); err == nil {
|
||||||
|
s.emitNestMessage(a)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nestReply asks one crow once and reads its KIND header. ok means the reply
|
// nestReply asks one crow once and reads its KIND header. ok means the reply
|
||||||
|
|||||||
@@ -142,6 +142,32 @@ func (s *NestStore) Create(name string, crows []string) (Nest, error) {
|
|||||||
return copyNest(n), nil
|
return copyNest(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveCrow takes one crow out of a nest's membership (the owner's retire
|
||||||
|
// call, #45). An unknown nest or a crow that is not a member is a no-op: the
|
||||||
|
// removal is idempotent.
|
||||||
|
func (s *NestStore) RemoveCrow(nestID, crow string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.made {
|
||||||
|
if s.made[i].ID != nestID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := slices.Index(s.made[i].Crows, crow)
|
||||||
|
if j < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
old := s.made[i].Crows
|
||||||
|
rest := slices.Clone(old)
|
||||||
|
s.made[i].Crows = slices.Delete(rest, j, j+1)
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
s.made[i].Crows = old
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// save writes atomically, like the ledger. The caller holds s.mu.
|
// save writes atomically, like the ledger. The caller holds s.mu.
|
||||||
func (s *NestStore) save() error {
|
func (s *NestStore) save() error {
|
||||||
data, err := json.MarshalIndent(s.made, "", " ")
|
data, err := json.MarshalIndent(s.made, "", " ")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,11 +37,13 @@ type Server struct {
|
|||||||
msgs *MessageStore
|
msgs *MessageStore
|
||||||
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
||||||
beat time.Duration
|
beat time.Duration
|
||||||
askTimeout time.Duration // an ask_owner unanswered past this reads as rejected (#41, fail safe like WebApprover)
|
askTimeout atomic.Int64 // nanos: an ask_owner unanswered past this reads as rejected (#41, fail safe like WebApprover). Atomic because handlers and the watchdog emit path read it concurrently, and tests narrow the window while the server runs (#45).
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
|
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
|
||||||
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat, askTimeout: 10 * time.Minute}
|
s := &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
|
||||||
|
s.askTimeout.Store(int64(10 * time.Minute))
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithMessages attaches the nest conversation log (chatroom path).
|
// WithMessages attaches the nest conversation log (chatroom path).
|
||||||
|
|||||||
Reference in new issue
Block a user