feat(ask-owner): owner-only fail-safe decisions in the nest log (#41) #49
No files matched your search
@@ -26,7 +26,7 @@ Each SSE message: `event: <name>` and `data: <JSON>`. Every payload includes `li
|
|||||||
| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 |
|
| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 |
|
||||||
| GET | `/api/trust` | | ledger JSON |
|
| GET | `/api/trust` | | ledger JSON |
|
||||||
|
|
||||||
Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe.
|
Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe. A nest `ask_owner` uses the same window and the same rule: unanswered reads as `rejected`.
|
||||||
|
|
||||||
## Nest messages
|
## Nest messages
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ 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. |
|
||||||
| `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`. |
|
| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. |
|
||||||
|
|
||||||
@@ -52,8 +53,8 @@ Every nest event carries `nest_id`, `ts` (RFC 3339) and one message record. The
|
|||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `msg_id` | string | `<nest_id>-<n>`, numbered from 1 in nest order. |
|
| `msg_id` | string | `<nest_id>-<n>`, numbered from 1 in nest order. |
|
||||||
| `kind` | string | One of the seven kinds above. |
|
| `kind` | string | One of the kinds above. |
|
||||||
| `author` | string | `"owner"`, a crow name, `"harness"` (nudge) or `"Sentry"` (flag). |
|
| `author` | string | `"owner"`, a crow name, `"harness"` (nudge), `"Sentry"` (flag) or a service role posting an `ask_owner` (`"Quill"`, `"Sentry"`). |
|
||||||
| `text` | string | The message body. |
|
| `text` | string | The message body. |
|
||||||
| `to` | string | The @-addressed member, or `""`. `deliver`/`claim` default to the requester they answer. |
|
| `to` | string | The @-addressed member, or `""`. `deliver`/`claim` default to the requester they answer. |
|
||||||
| `refers_to` | string | The `msg_id` this responds to (claim/deliver/nudge point at their request/expectation), or `""`. |
|
| `refers_to` | string | The `msg_id` this responds to (claim/deliver/nudge point at their request/expectation), or `""`. |
|
||||||
@@ -61,6 +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. |
|
||||||
|
| `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). |
|
||||||
|
|
||||||
### SSE event
|
### SSE event
|
||||||
|
|
||||||
@@ -74,7 +77,8 @@ Reconnect works the same as the list stream: each SSE message carries an `id:` l
|
|||||||
|
|
||||||
| Method | Path | Body / query | Returns |
|
| Method | Path | Body / query | Returns |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). 404 unknown nest; 400 with `{"message"}` for bad input; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. |
|
| GET | `/api/nests/{nest_id}/messages` | | The nest's records (payload table above), each with `approval_phrase` and a computed `resolved`. 404 unknown nest. |
|
||||||
|
| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. |
|
||||||
| GET | `/api/events` | `?nest_id=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
|
| GET | `/api/events` | `?nest_id=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
|
||||||
|
|
||||||
### Tagging [decided 2026-10-27]
|
### Tagging [decided 2026-10-27]
|
||||||
@@ -84,8 +88,8 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev
|
|||||||
### Guardrails
|
### Guardrails
|
||||||
|
|
||||||
- `text` 1–2,000 characters after trimming (same cap as a list item).
|
- `text` 1–2,000 characters after trimming (same cap as a list item).
|
||||||
- `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).
|
- `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` or `flag`.
|
- `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.
|
||||||
- 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`.
|
||||||
|
|
||||||
## Settled details
|
## Settled details
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// askNest posts a standard ask_owner from Quill and returns the nest id and
|
||||||
|
// the stored record. Quill is an owner-facing service role, so this is the
|
||||||
|
// shape the spec (CHATROOM_SPEC section 4/6) calls for.
|
||||||
|
func askNest(t *testing.T, ts *httptest.Server) (string, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
code, ask := postMsg(t, ts, id, `{"author":"Quill","kind":"ask_owner","to":"owner",`+
|
||||||
|
`"text":"Soot wrote fiction on a retrieval job and is quarantined. Reply with the phrase to reinstate the crow.",`+
|
||||||
|
`"approval_phrase":"reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("ask_owner post: want 201, got %d %v", code, ask)
|
||||||
|
}
|
||||||
|
return id, ask
|
||||||
|
}
|
||||||
|
|
||||||
|
// askResolved reads the nest log and returns the ask's computed resolved field.
|
||||||
|
func askResolved(t *testing.T, ts *httptest.Server, nestID, askID string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, m := range nestMessages(t, ts, nestID) {
|
||||||
|
if m["msg_id"] == askID {
|
||||||
|
r, ok := m["resolved"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ask record has no string resolved field: %v", m)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("ask %s not in the nest log", askID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerPostedAndSurfaced(t *testing.T) {
|
||||||
|
ts, _ := newTestNestServer(t)
|
||||||
|
id, ask := askNest(t, ts)
|
||||||
|
if ask["kind"] != "ask_owner" || ask["author"] != "Quill" || ask["to"] != "owner" {
|
||||||
|
t.Fatalf("bad ask record: %v", ask)
|
||||||
|
}
|
||||||
|
if ask["approval_phrase"] != "reinstate Hugin" {
|
||||||
|
t.Fatalf("POST record must carry approval_phrase, got %v", ask["approval_phrase"])
|
||||||
|
}
|
||||||
|
if r := ask["resolved"]; r != "" {
|
||||||
|
t.Fatalf("a fresh ask must read unresolved, got %v", r)
|
||||||
|
}
|
||||||
|
for _, m := range nestMessages(t, ts, id) {
|
||||||
|
if m["msg_id"] == ask["msg_id"] {
|
||||||
|
if m["approval_phrase"] != "reinstate Hugin" {
|
||||||
|
t.Fatalf("GET must surface approval_phrase, got %v", m)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("ask not in GET log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerOnlyOwnerResolves(t *testing.T) {
|
||||||
|
ts, _ := newTestNestServer(t)
|
||||||
|
id, ask := askNest(t, ts)
|
||||||
|
askID := ask["msg_id"].(string)
|
||||||
|
|
||||||
|
code, reply := postMsg(t, ts, id, `{"author":"Hugin","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("crow reply: %d %v", code, reply)
|
||||||
|
}
|
||||||
|
if r := askResolved(t, ts, id, askID); r == "approved" {
|
||||||
|
t.Fatalf("a crow's reply must never approve an ask_owner, got resolved=%v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, yes := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"Reinstate Hugin"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner yes: %d %v", code, yes)
|
||||||
|
}
|
||||||
|
if r := askResolved(t, ts, id, askID); r != "approved" {
|
||||||
|
t.Fatalf("the owner's exact phrase must approve (case-insensitive), got resolved=%v", r)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, m := range nestMessages(t, ts, id) {
|
||||||
|
if m["author"] == "owner" && m["refers_to"] == askID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the owner's reply message must be in the log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerWrongPhraseStaysOpen(t *testing.T) {
|
||||||
|
ts, _ := newTestNestServer(t)
|
||||||
|
id, ask := askNest(t, ts)
|
||||||
|
askID := ask["msg_id"].(string)
|
||||||
|
|
||||||
|
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"sounds good"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("owner reply: %d %v", code, out)
|
||||||
|
}
|
||||||
|
if r := askResolved(t, ts, id, askID); r != "" {
|
||||||
|
t.Fatalf("anything but the exact phrase is not a yes, got resolved=%v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerTimeoutFailSafe(t *testing.T) {
|
||||||
|
ts, s := newTestNestServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||||
|
stale, err := s.msgs.Append(Message{
|
||||||
|
NestID: id,
|
||||||
|
Kind: "ask_owner",
|
||||||
|
Author: "Sentry",
|
||||||
|
Text: "Inky keeps drifting off task. Retire the crow?",
|
||||||
|
To: "owner",
|
||||||
|
ApprovalPhrase: "retire inky",
|
||||||
|
TaggedBy: "header",
|
||||||
|
Timestamp: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.askTimeout = time.Minute
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskOwnerCrowHeaderRetagged(t *testing.T) {
|
||||||
|
ts, _ := newTestNestServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||||
|
code, out := postMsg(t, ts, id, `{"author":"Hugin","kind":"ask_owner","to":"owner","text":"may I retire the old flyer template?"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("crow ask_owner header: want 201 with a retag, got %d %v", code, out)
|
||||||
|
}
|
||||||
|
if out["kind"] != "say" {
|
||||||
|
t.Fatalf("crows may not post ask_owner: the header must drop to the default tag, got kind=%v", out["kind"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-34
@@ -18,6 +18,15 @@ var crowPostable = map[string]bool{
|
|||||||
"decline": true,
|
"decline": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kindAskOwner is a decision only the owner can make (#41). It is postable by
|
||||||
|
// the owner-facing service roles and the owner, never by an ordinary crow: a
|
||||||
|
// crow's ask_owner header is dropped and the message tags as it otherwise
|
||||||
|
// would be. Jev's kind question never proposes it; the header bypasses
|
||||||
|
// tagging entirely, like nudge and flag.
|
||||||
|
const kindAskOwner = "ask_owner"
|
||||||
|
|
||||||
|
var ownerFacingRoles = map[string]bool{"Quill": true, "Sentry": true}
|
||||||
|
|
||||||
// nestReplySystem tells an addressed crow the response contract: its reply
|
// nestReplySystem tells an addressed crow the response contract: its reply
|
||||||
// must open with a KIND line the harness reads (must-respond).
|
// must open with a KIND line the harness reads (must-respond).
|
||||||
const markerNestReply = "This is a chatroom request."
|
const markerNestReply = "This is a chatroom request."
|
||||||
@@ -47,22 +56,26 @@ func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
all := s.msgs.All(nestID)
|
all := s.msgs.All(nestID)
|
||||||
out := make([]map[string]any, 0, len(all))
|
out := make([]map[string]any, 0, len(all))
|
||||||
for _, m := range all {
|
for _, m := range all {
|
||||||
out = append(out, nestRecord(m))
|
out = append(out, s.nestRecord(m))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nestRecord is the EVENTS.md wire shape for one message.
|
// nestRecord is the EVENTS.md wire shape for one message. resolved is computed
|
||||||
func nestRecord(m Message) map[string]any {
|
// at read time: an ask_owner approves only on the owner's exact phrase and
|
||||||
|
// rejects on the fail-safe timeout; every other message reads "".
|
||||||
|
func (s *Server) nestRecord(m Message) map[string]any {
|
||||||
rec := map[string]any{
|
rec := map[string]any{
|
||||||
"msg_id": m.MsgID(),
|
"msg_id": m.MsgID(),
|
||||||
"kind": m.Kind,
|
"kind": m.Kind,
|
||||||
"author": m.Author,
|
"author": m.Author,
|
||||||
"text": m.Text,
|
"text": m.Text,
|
||||||
"to": m.To,
|
"to": m.To,
|
||||||
"refers_to": m.RefersTo,
|
"refers_to": m.RefersTo,
|
||||||
"tagged_by": m.TaggedBy,
|
"tagged_by": m.TaggedBy,
|
||||||
"ts": m.Timestamp,
|
"approval_phrase": m.ApprovalPhrase,
|
||||||
|
"resolved": s.askResolved(m),
|
||||||
|
"ts": m.Timestamp,
|
||||||
}
|
}
|
||||||
if m.Mode != "" {
|
if m.Mode != "" {
|
||||||
rec["mode"] = m.Mode
|
rec["mode"] = m.Mode
|
||||||
@@ -74,6 +87,29 @@ func nestRecord(m Message) map[string]any {
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// sides); rejected when nothing answered it before the timeout; "" while open.
|
||||||
|
// Anything but an explicit yes is a no.
|
||||||
|
func (s *Server) askResolved(m Message) string {
|
||||||
|
if m.Kind != kindAskOwner {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
|
||||||
|
if phrase != "" {
|
||||||
|
for _, r := range s.msgs.All(m.NestID) {
|
||||||
|
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
||||||
|
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
|
||||||
|
return "approved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > s.askTimeout {
|
||||||
|
return "rejected"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
nestID := r.PathValue("nest_id")
|
nestID := r.PathValue("nest_id")
|
||||||
nest, found := s.nests.Get(nestID)
|
nest, found := s.nests.Get(nestID)
|
||||||
@@ -83,13 +119,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Invited int `json:"invited"`
|
Invited int `json:"invited"`
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
RefersTo string `json:"refers_to"`
|
RefersTo string `json:"refers_to"`
|
||||||
|
ApprovalPhrase string `json:"approval_phrase"`
|
||||||
}
|
}
|
||||||
if !decode(w, r, &body) {
|
if !decode(w, r, &body) {
|
||||||
return
|
return
|
||||||
@@ -106,9 +143,19 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ask_owner is the owner's decision channel (#41). Only the owner and the
|
||||||
|
// owner-facing service roles may park one; an ordinary crow's header is
|
||||||
|
// dropped so the message tags as it otherwise would (harmless talk, not
|
||||||
|
// an error).
|
||||||
|
askOwner := body.Kind == kindAskOwner
|
||||||
|
if askOwner && body.Author != "owner" && !ownerFacingRoles[body.Author] {
|
||||||
|
body.Kind = ""
|
||||||
|
askOwner = false
|
||||||
|
}
|
||||||
|
|
||||||
if body.Author == "owner" {
|
if body.Author == "owner" {
|
||||||
// owner can always post
|
// owner can always post
|
||||||
} else if !slices.Contains(nest.Crows, body.Author) {
|
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
|
||||||
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -119,13 +166,21 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if body.Kind != "" && body.Author != "owner" && !crowPostable[body.Kind] {
|
if body.Kind != "" && body.Author != "owner" && !askOwner && !crowPostable[body.Kind] {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
||||||
kind, taggedBy := s.tagKind(body.Kind, body.Text)
|
// A validated ask_owner header bypasses the classifier: Jev's kind
|
||||||
|
// question never proposes it, and a decision for the owner must not be
|
||||||
|
// re-tagged away.
|
||||||
|
var kind, taggedBy string
|
||||||
|
if askOwner {
|
||||||
|
kind, taggedBy = kindAskOwner, "header"
|
||||||
|
} else {
|
||||||
|
kind, taggedBy = s.tagKind(body.Kind, body.Text)
|
||||||
|
}
|
||||||
|
|
||||||
// claim/deliver/decline answer a request: with no explicit address they go
|
// claim/deliver/decline answer a request: with no explicit address they go
|
||||||
// back to whoever posted the message they refer to (EVENTS.md deliver rule).
|
// back to whoever posted the message they refer to (EVENTS.md deliver rule).
|
||||||
@@ -161,18 +216,25 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// approval_phrase is honored only when the resulting kind is ask_owner.
|
||||||
|
phrase := strings.TrimSpace(body.ApprovalPhrase)
|
||||||
|
if kind != kindAskOwner {
|
||||||
|
phrase = ""
|
||||||
|
}
|
||||||
|
|
||||||
msg := Message{
|
msg := Message{
|
||||||
NestID: nestID,
|
NestID: nestID,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Author: body.Author,
|
Author: body.Author,
|
||||||
Text: body.Text,
|
Text: body.Text,
|
||||||
To: body.To,
|
To: body.To,
|
||||||
RefersTo: body.RefersTo,
|
RefersTo: body.RefersTo,
|
||||||
TaggedBy: taggedBy,
|
TaggedBy: taggedBy,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
ModeTagged: modeBy,
|
ModeTagged: modeBy,
|
||||||
Invited: invited,
|
Invited: invited,
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
ApprovalPhrase: phrase,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
record, err := s.msgs.Append(msg)
|
record, err := s.msgs.Append(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,7 +245,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
s.triggerResponse(nest, record)
|
s.triggerResponse(nest, record)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, nestRecord(record))
|
writeJSON(w, http.StatusCreated, s.nestRecord(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
// triggerResponse is the event-driven response rule (no idle loops): a
|
// triggerResponse is the event-driven response rule (no idle loops): a
|
||||||
@@ -251,7 +313,7 @@ func (s *Server) tagKind(header, text string) (string, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) emitNestMessage(m Message) {
|
func (s *Server) emitNestMessage(m Message) {
|
||||||
rec := nestRecord(m)
|
rec := s.nestRecord(m)
|
||||||
rec["nest_id"] = m.NestID
|
rec["nest_id"] = m.NestID
|
||||||
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -24,7 +24,11 @@ type Message struct {
|
|||||||
Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C)
|
Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C)
|
||||||
ModeTagged string `json:"mode_tagged_by,omitempty"`
|
ModeTagged string `json:"mode_tagged_by,omitempty"`
|
||||||
Invited int `json:"invited,omitempty"`
|
Invited int `json:"invited,omitempty"`
|
||||||
Timestamp string `json:"ts"`
|
// ApprovalPhrase is the exact phrase that resolves an ask_owner (#41):
|
||||||
|
// only an owner reply equal to it is a yes. Anything else, including
|
||||||
|
// silence and timeout, is not.
|
||||||
|
ApprovalPhrase string `json:"approval_phrase,omitempty"`
|
||||||
|
Timestamp string `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Message) MsgID() string { return fmt.Sprintf("%s-%d", m.NestID, m.ID) }
|
func (m Message) MsgID() string { return fmt.Sprintf("%s-%d", m.NestID, m.ID) }
|
||||||
|
|||||||
@@ -28,17 +28,18 @@ const (
|
|||||||
// Server is the web front door: the same pipeline as the command line,
|
// Server is the web front door: the same pipeline as the command line,
|
||||||
// with web approvals and an SSE hub instead of stdin and stdout.
|
// with web approvals and an SSE hub instead of stdin and stdout.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
p *Pipeline
|
p *Pipeline
|
||||||
approver *WebApprover
|
approver *WebApprover
|
||||||
hub *Hub
|
hub *Hub
|
||||||
nests *NestStore
|
nests *NestStore
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat, askTimeout: 10 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithMessages attaches the nest conversation log (chatroom path).
|
// WithMessages attaches the nest conversation log (chatroom path).
|
||||||
|
|||||||
Reference in new issue
Block a user