- commit
- 9786a0a67f51102ab0179f79d4074a68c22f987d
- parent
- cef34c70332a419493c111ed56539339936e907e
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 30 | +++++++++++++++++-- |
| M | internal/ | 1 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 3 | ++ |
| M | internal/ | 4 | +++ |
| M | internal/ | 48 | +++++++++++++++++++++++++++++- |
@@ -44,6 +44,12 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
4444 switch method {4545 case "listagents":4646 return agents, nil47+ case "listcallbacks":48+ return []any{49+ map[string]any{"id": "cb-active", "owner": agentActive, "host": "hooks.example", "filter": map[string]any{"kinds": []int{1621, 1111}, "#p": []string{agentActive}}, "created": now - 3600, "lastDelivery": now - 60, "lastStatus": "ok", "failures": 0, "paused": false},50+ map[string]any{"id": "cb-paused", "owner": agentActive, "host": "worker.example:8443", "filter": map[string]any{"kinds": []int{9}}, "created": now - 3600, "lastDelivery": 0, "lastStatus": "paused after 20 failures: HTTP 500", "failures": 20, "paused": true},51+ map[string]any{"id": "cb-other", "owner": strings.Repeat("c", 64), "host": "elsewhere.example", "filter": map[string]any{"kinds": []int{1}}, "created": now, "lastDelivery": 0, "lastStatus": "", "failures": 0, "paused": false},52+ }, nil4753 case "browseagent":4854 var query struct {4955 Agent string `json:"agent"`@@ -104,7 +110,11 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
104110 `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`,105111 `<textarea name="repos"`,106112 `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`,107- `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr></table>`,113+ `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr><tr><th>callbacks</th><td>2 active, 1 paused</td></tr></table>`,114+ `<tr><th>callbacks</th><td><agent-callback data-state="active"><code>hooks.example</code> kinds 1621, 1111 | <span data-state="active" title="ok">active</span> | delivered <time datetime="`,115+ `<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""cb-active""><button>Pause</button></rpc-form><rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""cb-active""><button>Remove</button></rpc-form></agent-callback>`,116+ `<agent-callback data-state="paused"><code>worker.example:8443</code> kinds 9 | <span data-state="paused" title="paused after 20 failures: HTTP 500">paused</span> <rpc-form method="resumecallback" refresh><input type="hidden" name="param" value=""cb-paused""><button>Resume</button></rpc-form>`,117+ `<tr><th>callbacks</th><td>none</td></tr>`,108118 `<code>/mcp</code> 2026-07-28`,109119 `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,110120 `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,@@ -119,7 +129,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
119129 if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) {120130 t.Errorf("revoked card should show state without controls: %s", revokedCard)121131 }122- if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {132+ if strings.Contains(body, "elsewhere.example") {133+ t.Error("a callback of a key without a card was shown")134+ }135+ if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" listcallbacks:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {123136 t.Fatalf("backend calls = %s", got)124137 }125138 if !strings.Contains(string(backend.params[0]), agentActive) {@@ -155,7 +168,7 @@ func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) {
155168 if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") {156169 t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))])157170 }158- if backend.call != "listagents:"+backend.policy.Owner {171+ if backend.call != "listcallbacks:"+backend.policy.Owner {159172 t.Fatalf("empty list should not query an agent: %s", backend.call)160173 }161174 @@ -193,4 +206,15 @@ func TestAgentStateHelpers(t *testing.T) {
193206 if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 {194207 t.Errorf("counts = %v", counts)195208 }209+ callbacks := []any{210+ map[string]any{"owner": agentActive, "paused": false, "filter": map[string]any{"kinds": []any{float64(1621), float64(1111)}}},211+ map[string]any{"owner": agentActive, "paused": true, "filter": map[string]any{}},212+ map[string]any{"owner": agentPaused, "paused": false},213+ }214+ if mine := callbacksFor(callbacks, agentActive); len(mine) != 2 || callbackKinds(mine[0]) != "1621, 1111" || callbackKinds(mine[1]) != "" || callbackState(mine[1]) != "paused" {215+ t.Errorf("callbacksFor = %v", mine)216+ }217+ if counts := callbackCounts(callbacks); counts["active"] != 2 || counts["paused"] != 1 {218+ t.Errorf("callback counts = %v", counts)219+ }196220 }@@ -50,6 +50,7 @@
5050 <tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr>5151 <tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr>5252 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}53+<tr><th>callbacks</th><td>{{range $c := callbacksFor $.Callbacks $pk}}{{$cm := asMap $c}}{{$cs := callbackState $c}}{{$cid := str (index $cm "id")}}<agent-callback data-state="{{$cs}}"><code>{{str (index $cm "host")}}</code> kinds {{callbackKinds $c}} | <span data-state="{{$cs}}" title="{{str (index $cm "lastStatus")}}">{{$cs}}</span>{{with index $cm "lastDelivery"}}{{if when .}} | delivered <time datetime="{{datetime .}}">{{when .}}</time>{{end}}{{end}} {{if eq $cs "paused"}}<rpc-form method="resumecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr>5354 </table>5455 <p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a></p>5556 </agent-card>@@ -140,7 +140,7 @@
140140 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>141141 <section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}}142142 143-{{define "panel-agents"}}{{$counts := agentCounts .Feed}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr></table></section>143+{{define "panel-agents"}}{{$counts := agentCounts .Feed}}{{$callbacks := callbackCounts .Callbacks}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr><tr><th>callbacks</th><td>{{index $callbacks "active"}} active, {{index $callbacks "paused"}} paused</td></tr></table></section>144144 <section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section>145145 <section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>146146 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}@@ -142,6 +142,9 @@ agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;pa
142142 agent-card td code{color:var(--muted)}143143 agent-card p{margin:8px 0 0}agent-card rpc-form{display:inline-block;border:0;padding:0;margin:0 8px 0 0;vertical-align:top}agent-card rpc-form output:empty{display:none}144144 agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;vertical-align:top}145+/* agent-callback: one registered callback on an agent card, with its controls. */146+agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}147+agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px}145148 #rows:has(td[data-state]){table-layout:fixed}#rows:has(td[data-state]) td:nth-child(3){width:110px}#rows:has(td[data-state]) td:last-child{width:150px}146149 [data-state=active],#rows td[data-state=active]{color:var(--accent)}[data-state=paused],#rows td[data-state=paused]{color:var(--bark)}[data-state=revoked],#rows td[data-state=revoked]{color:var(--alert)}147150 agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}@@ -258,6 +258,10 @@ func parseTemplates() (*template.Template, error) {
258258 "agentScope": agentScope,259259 "agentLabel": agentLabel,260260 "agentCounts": agentCounts,261+ "callbacksFor": callbacksFor,262+ "callbackState": callbackState,263+ "callbackKinds": callbackKinds,264+ "callbackCounts": callbackCounts,261265 "scopeList": scopeList,262266 "dateAfter": dateAfter,263267 "approvalViews": approvalViews,@@ -92,6 +92,8 @@ type PageData struct {
9292 Connections []any9393 // Rooms lists the rooms the viewer may see; the rooms rail renders it.9494 Rooms []any95+ // Callbacks lists the event callbacks shown beside each agent.96+ Callbacks []any9597 }9698 9799 func New(backend Backend, options Options) (*App, error) {@@ -922,6 +924,11 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
922924 return923925 }924926 data.Feed = browseRows(result)927+ // Callbacks are shown per agent; a failure here leaves the row empty928+ // rather than hiding the agents.929+ if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil {930+ data.Callbacks = browseRows(callbacks)931+ }925932 selected := request.URL.Query().Get("agent")926933 if selected == "" {927934 // Without a choice, show the first agent that can still publish.@@ -1034,13 +1041,52 @@ func agentCounts(agents []any) map[string]int {
10341041 return counts10351042 }10361043 1044+// callbacksFor picks the callbacks one key registered.1045+func callbacksFor(callbacks []any, pubkey string) []any {1046+ var out []any1047+ for _, callback := range callbacks {1048+ if plainString(valueMap(callback)["owner"]) == pubkey {1049+ out = append(out, callback)1050+ }1051+ }1052+ return out1053+}1054+1055+// callbackState is active or paused; a paused callback carries the reason1056+// in its lastStatus.1057+func callbackState(callback any) string {1058+ if valueMap(callback)["paused"] == true {1059+ return "paused"1060+ }1061+ return "active"1062+}1063+1064+// callbackKinds lists the kinds a callback's filter names.1065+func callbackKinds(callback any) string {1066+ kinds, _ := valueMap(valueMap(callback)["filter"])["kinds"].([]any)1067+ parts := make([]string, 0, len(kinds))1068+ for _, kind := range kinds {1069+ parts = append(parts, plainString(kind))1070+ }1071+ return strings.Join(parts, ", ")1072+}1073+1074+// callbackCounts tallies callbacks by state for the panel.1075+func callbackCounts(callbacks []any) map[string]int {1076+ counts := map[string]int{"active": 0, "paused": 0}1077+ for _, callback := range callbacks {1078+ counts[callbackState(callback)]++1079+ }1080+ return counts1081+}1082+10371083 // dateAfter is the date field value for a day count from today, in UTC.10381084 func dateAfter(days int) string {10391085 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")10401086 }10411087 10421088 var supportedMethods = []string{1043- "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains",1089+ "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",10441090 }10451091 10461092 func (a *App) card(writer http.ResponseWriter) {