~bigbes/sr-ht-spec

ref: 824788ab8269bd6c58de5848bc4545450fac7aaf sr-ht-spec/web/inbox.go -rw-r--r-- 6.2 KiB
824788ab — Eugene Blikh mcpsrv: mark /mcp uncacheable, fail closed on origin, split tool errors from faults 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
package web

import (
	"log/slog"
	"net/http"
	"strconv"
	"time"

	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// inboxData is the review-queue page: the open proposals waiting on the owner,
// and the digest of what auto-merged without stopping for review.
//
// NewCount is how many leading digest rows auto-merged since the owner last
// marked it seen. The digest is newest-first and "new" means merged after the
// mark, so the new rows are exactly the first NewCount — the template draws the
// "since you last looked" divider after them and shows the mark-as-seen action
// only when there is something new to clear.
//
// The two listings are not the same kind of thing, which is why only one of them
// is a chrome.RepoList. The open queue is a plain listing and renders through
// ecore's "srht-repo-table", so a proposal waiting on the owner looks like a
// repository on the sibling services. The digest is not a listing: it carries a
// per-row "new" badge and a divider row inserted between items at NewCount, and
// a partial over a flat []ListItem can express neither — Meta is plain text, so
// a badge would come out escaped, and nothing can interleave a row that is not
// an item. It stays this package's own markup rather than being flattened into
// something that loses the one thing the page is for.
type inboxData struct {
	Open     chrome.RepoList
	Digest   []proposalRow
	NewCount int
}

// proposalRow is one proposal as a listing line: enough to decide whether to
// open it, and the link that does. New marks a digest row that auto-merged
// since the owner last looked; it is always false for the open queue.
type proposalRow struct {
	Href     string
	ID       int
	Space    string
	Title    string
	Agent    string
	Approval string
	New      bool
}

// handleInbox renders the review queue: every open proposal on the instance,
// plus the digest of recently policy-merged content. It is the backstop the
// design describes — the link an agent hands you is the normal way in, and this
// catches the work no link reached.
func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
	if !s.allowRead(w, r, formatHTML) {
		return
	}
	open, err := s.reader.Inbox(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	digest, err := s.reader.Digest(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	// The GET stays pure: it reads the mark to draw the divider but never moves
	// it. Advancing is handleInboxSeen's job, behind a POST.
	mark, marked, err := s.reader.DigestMark(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}

	digestRows, newCount := digestRows(digest, mark, marked)

	vd := s.view(r, "Review queue")
	vd.Data = inboxData{
		Open:     openQueue(open),
		Digest:   digestRows,
		NewCount: newCount,
	}
	if err := s.pages.Render(w, http.StatusOK, "inbox", vd); err != nil {
		slog.ErrorContext(r.Context(), "rendering a page failed after it was answered",
			"page", "inbox", "path", r.URL.Path, scribe.Err(err))
	}
}

// handleInboxSeen advances the owner's digest mark to now, then redirects back
// to the queue so a reload does not re-submit. It is the one write the review
// queue makes; keeping it a POST is what lets handleInbox stay a pure read.
//
// Only the owner may move their own mark. The cross-site guard this handler
// used to call for itself is csrf.Require on the router now (Handler).
func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
	if !authn.PrincipalFromContext(r.Context()).IsOwner() {
		s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
		return
	}
	if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
		s.fail(w, r, err)
		return
	}
	http.Redirect(w, r, "/inbox", http.StatusSeeOther)
}

// emptyQueue is what the open queue says when there is nothing waiting. It is
// the sentence the page used to carry inline, moved to where the partial reads
// it from.
const emptyQueue = "No open proposals. Your queue is clear."

// openQueue turns the open proposals into ecore's listing shape.
//
// The title carries the id because a proposal is addressed by number and the
// number is what an agent quotes back; the space and the agent are Meta, which
// the table renders as its own columns in order. There is no Updated: a proposal
// row's useful timestamp is when it was opened, and the read layer does not
// carry one — see the report on this uplift.
func openQueue(ps []service.Proposal) chrome.RepoList {
	list := chrome.RepoList{Empty: emptyQueue}
	for _, p := range ps {
		row := proposalRowOf(p)
		list.Items = append(list.Items, chrome.ListItem{
			Href:  row.Href,
			Title: "#" + strconv.Itoa(row.ID) + " — " + row.Title,
			Meta:  []string{row.Space, row.Agent},
		})
	}
	return list
}

// digestRows turns the digest proposals into rows, flagging each that
// auto-merged after the mark as new and counting them. With no mark yet
// (marked false) the whole digest is new — the owner has never cleared it. The
// digest arrives newest-first and a row is new iff its merge time is after the
// mark, so the new rows are the leading run and newCount is their length.
func digestRows(ps []service.Proposal, mark time.Time, marked bool) ([]proposalRow, int) {
	rows := make([]proposalRow, 0, len(ps))
	newCount := 0
	for _, p := range ps {
		row := proposalRowOf(p)
		row.New = !marked || (p.Resolved != nil && p.Resolved.After(mark))
		if row.New {
			newCount++
		}
		rows = append(rows, row)
	}
	return rows, newCount
}

// proposalRowOf builds one listing row, deriving its link from the space and id
// — the same stable /~owner/space/p/<id> shape the write plane hands back.
func proposalRowOf(p service.Proposal) proposalRow {
	return proposalRow{
		Href:     proposalHref(p),
		ID:       p.ID,
		Space:    p.Space.String(),
		Title:    p.Title,
		Agent:    p.Agent,
		Approval: string(p.Approval),
	}
}

// proposalHref is the review-page link for a proposal: the same path the
// proposal URL uses, minus the origin, so it works as a relative link in the UI.
func proposalHref(p service.Proposal) string {
	return "/" + p.Space.String() + "/p/" + strconv.Itoa(p.ID)
}