- commit
- ee0004f7e60c8b464c09d2bc6f0d5969a6f14c34
- parent
- e5a69f652e8317892aa6863a1d16fb2823fa9909
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 5 | +- |
| A | internal/ | 316 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 164 | ++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 5 | + |
| M | scripts/ | 26 | ++ |
@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
3434 }3535 3636 func clientBrowseMethod(method string) bool {37- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)37+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)3838 }3939 4040 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -64,6 +64,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
6464 if approvalBrowseMethod(method) {6565 return t.executeApprovals(ctx, actor, method, params)6666 }67+ if jobBrowseMethod(method) {68+ return t.executeJobs(ctx, actor, method, params)69+ }6770 q := clientBrowseRequest{}6871 if len(params) > 0 {6972 if err := json.Unmarshal(params[0], &q); err != nil {@@ -0,0 +1,316 @@
1+package daemon2+3+// Long task browsing: the NIP-90 job requests the caller may see, each with4+// its newest feedback status and its result when one exists, and one5+// request with its feedback timeline and results. Both read through the6+// tenant gate with the caller's session, like the other browse methods.7+8+import (9+ "context"10+ "encoding/json"11+ "errors"12+ "fmt"13+ "strconv"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/internal/event"17+ "github.com/FelineStateMachine/tinyrelay/internal/relay"18+ "github.com/FelineStateMachine/tinyrelay/internal/storage"19+)20+21+// jobScan bounds how many stored requests or answers one listing walks.22+const jobScan = 100023+24+var jobStates = []string{"open", "done", "all"}25+26+// jobRequestKinds lists every request kind, so one filter covers the range.27+var jobRequestKinds = func() []int {28+ kinds := make([]int, 0, event.KIND_JOB_REQUEST_MAX-event.KIND_JOB_REQUEST_MIN+1)29+ for kind := event.KIND_JOB_REQUEST_MIN; kind <= event.KIND_JOB_REQUEST_MAX; kind++ {30+ kinds = append(kinds, kind)31+ }32+ return kinds33+}()34+35+type jobBrowseRequest struct {36+ Limit int `json:"limit"`37+ Cursor string `json:"cursor"`38+ State string `json:"state"`39+ Mine bool `json:"mine"`40+ ID string `json:"id"`41+ Event string `json:"event"`42+}43+44+// jobInput is one i tag of a request.45+type jobInput struct {46+ Data string `json:"data"`47+ Type string `json:"type"`48+ Relay string `json:"relay,omitempty"`49+ Marker string `json:"marker,omitempty"`50+}51+52+// jobParam is one param tag of a request.53+type jobParam struct {54+ Key string `json:"key"`55+ Value string `json:"value"`56+}57+58+// jobItem is one request as a browser sees it, settled against its answers.59+type jobItem struct {60+ ID string `json:"id"`61+ Kind int `json:"kind"`62+ Requester string `json:"requester"`63+ Providers []string `json:"providers"`64+ Inputs []jobInput `json:"inputs"`65+ Output string `json:"output,omitempty"`66+ Params []jobParam `json:"params"`67+ Bid string `json:"bid,omitempty"`68+ Relays []string `json:"relays"`69+ Encrypted bool `json:"encrypted,omitempty"`70+ Content string `json:"content"`71+ CreatedAt int64 `json:"created_at"`72+ Expires int64 `json:"expires,omitempty"`73+ State string `json:"state"`74+ Status string `json:"status,omitempty"`75+ Feedback *jobAnswer `json:"feedback,omitempty"`76+ Result *jobAnswer `json:"result,omitempty"`77+ Event event.Event `json:"event"`78+}79+80+// jobAnswer is one feedback or result event from a service provider.81+type jobAnswer struct {82+ ID string `json:"id"`83+ Kind int `json:"kind"`84+ Provider string `json:"provider"`85+ Status string `json:"status,omitempty"`86+ Info string `json:"info,omitempty"`87+ Amount string `json:"amount,omitempty"`88+ Invoice string `json:"invoice,omitempty"`89+ Content string `json:"content"`90+ CreatedAt int64 `json:"created_at"`91+}92+93+func jobBrowseMethod(method string) bool {94+ return method == "browsejobs" || method == "browsejob"95+}96+97+func (t *Tenant) executeJobs(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {98+ if !t.Policy().Features.Jobs {99+ return nil, errors.New("restricted: long tasks are switched off on this relay")100+ }101+ q := jobBrowseRequest{}102+ if len(params) > 0 {103+ if err := json.Unmarshal(params[0], &q); err != nil {104+ return nil, fmt.Errorf("invalid: job parameters: %w", err)105+ }106+ }107+ if q.Limit <= 0 || q.Limit > 100 {108+ q.Limit = 50109+ }110+ switch method {111+ case "browsejobs":112+ return t.browseJobs(ctx, actor, q)113+ case "browsejob":114+ if q.ID == "" {115+ q.ID = q.Event116+ }117+ return t.browseJob(ctx, actor, q.ID)118+ }119+ return nil, errors.New("unsupported: browse operation")120+}121+122+// browseJobs lists the requests the caller may see, newest first. A request123+// is done once a result exists or its newest feedback reports success or an124+// error; every other request is open. Expired requests are not served.125+func (t *Tenant) browseJobs(ctx context.Context, actor string, q jobBrowseRequest) (any, error) {126+ if q.State == "" {127+ q.State = "all"128+ }129+ if !containsString(jobStates, q.State) {130+ return nil, errors.New("invalid: job state must be open, done or all")131+ }132+ if q.Mine && actor == "" {133+ return nil, errors.New("auth-required: sign in to list your own requests")134+ }135+ cursor, err := parseCollaborationCursor(q.Cursor)136+ if err != nil {137+ return nil, errors.New("invalid: job cursor")138+ }139+ session := browseSession(t, actor)140+ now := time.Now().Unix()141+ filter := event.Filter{Kinds: jobRequestKinds, Tags: map[string][]string{}}142+ if q.Mine {143+ filter.Authors = []string{actor}144+ }145+ items := make([]jobItem, 0, q.Limit)146+ next := ""147+ for scanned := 0; scanned < jobScan && next == ""; scanned += 100 {148+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: now, Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})149+ if err != nil {150+ return nil, err151+ }152+ visible := make([]jobItem, 0, len(page.Events))153+ for _, row := range page.Events {154+ cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}155+ if t.gate.CanSee(ctx, row, session, nil) {156+ visible = append(visible, jobItemFrom(row))157+ }158+ }159+ answers, err := t.jobAnswers(ctx, visible, session, now)160+ if err != nil {161+ return nil, err162+ }163+ for i := range visible {164+ jobSettle(&visible[i], answers[visible[i].ID])165+ if q.State != "all" && visible[i].State != q.State {166+ continue167+ }168+ if len(items) == q.Limit {169+ next = jobCursor(items[len(items)-1])170+ break171+ }172+ items = append(items, visible[i])173+ }174+ if !page.More || len(page.Events) == 0 {175+ break176+ }177+ if next == "" && len(items) == q.Limit {178+ next = jobCursor(items[len(items)-1])179+ }180+ }181+ return map[string]any{"items": items, "next_cursor": next}, nil182+}183+184+// browseJob returns one request with its feedback, oldest first, and its185+// results, newest first.186+func (t *Tenant) browseJob(ctx context.Context, actor, id string) (any, error) {187+ if len(id) != 64 {188+ return nil, errors.New("invalid: job id")189+ }190+ session := browseSession(t, actor)191+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: jobRequestKinds, Limit: intPtr(1)}}, session)192+ if err != nil {193+ return nil, err194+ }195+ if len(rows) != 1 {196+ return nil, errors.New("not found: job request")197+ }198+ item := jobItemFrom(rows[0])199+ answers, err := t.jobAnswers(ctx, []jobItem{item}, session, time.Now().Unix())200+ if err != nil {201+ return nil, err202+ }203+ jobSettle(&item, answers[item.ID])204+ feedback, results := []jobAnswer{}, []jobAnswer{}205+ for _, row := range answers[item.ID] {206+ if row.Kind == event.KIND_JOB_FEEDBACK {207+ feedback = append([]jobAnswer{jobAnswerFrom(row)}, feedback...)208+ } else {209+ results = append(results, jobAnswerFrom(row))210+ }211+ }212+ return map[string]any{"item": item, "feedback": feedback, "results": results}, nil213+}214+215+func jobItemFrom(e event.Event) jobItem {216+ item := jobItem{ID: e.ID, Kind: e.Kind, Requester: e.PubKey, Providers: event.TagValues(e, "p"), Inputs: []jobInput{}, Params: []jobParam{}, Relays: []string{}, Output: event.Tag(e, "output"), Bid: event.Tag(e, "bid"), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Event: e}217+ for _, tag := range e.Tags {218+ switch {219+ case len(tag) >= 3 && tag[0] == "i":220+ input := jobInput{Data: tag[1], Type: tag[2]}221+ if len(tag) > 3 {222+ input.Relay = tag[3]223+ }224+ if len(tag) > 4 {225+ input.Marker = tag[4]226+ }227+ item.Inputs = append(item.Inputs, input)228+ case len(tag) >= 3 && tag[0] == "param":229+ item.Params = append(item.Params, jobParam{Key: tag[1], Value: tag[2]})230+ case len(tag) >= 2 && tag[0] == "relays":231+ item.Relays = append(item.Relays, tag[1:]...)232+ case len(tag) >= 1 && tag[0] == "encrypted":233+ item.Encrypted = true234+ }235+ }236+ return item237+}238+239+func jobAnswerFrom(row event.Event) jobAnswer {240+ answer := jobAnswer{ID: row.ID, Kind: row.Kind, Provider: row.PubKey, Content: row.Content, CreatedAt: row.CreatedAt}241+ for _, tag := range row.Tags {242+ switch {243+ case len(tag) >= 2 && tag[0] == "status" && answer.Status == "":244+ answer.Status = tag[1]245+ if len(tag) > 2 {246+ answer.Info = tag[2]247+ }248+ case len(tag) >= 2 && tag[0] == "amount" && answer.Amount == "":249+ answer.Amount = tag[1]250+ if len(tag) > 2 {251+ answer.Invoice = tag[2]252+ }253+ }254+ }255+ return answer256+}257+258+// jobAnswers finds the feedback and results naming the given requests in259+// one query, newest first, keeping only what the session may see.260+func (t *Tenant) jobAnswers(ctx context.Context, items []jobItem, session relay.Session, now int64) (map[string][]event.Event, error) {261+ answers := make(map[string][]event.Event, len(items))262+ if len(items) == 0 {263+ return answers, nil264+ }265+ ids := make([]string, 0, len(items))266+ kinds := []int{event.KIND_JOB_FEEDBACK}267+ wanted := make(map[string]int, len(items))268+ for _, item := range items {269+ ids = append(ids, item.ID)270+ wanted[item.ID] = event.JobResultKind(item.Kind)271+ if !containsInt(kinds, wanted[item.ID]) {272+ kinds = append(kinds, wanted[item.ID])273+ }274+ }275+ rows, err := t.store.Query(ctx, event.Filter{Kinds: kinds, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: now, Access: storage.Access{PubKeys: session.PubKeys}, Limit: jobScan})276+ if err != nil {277+ return nil, err278+ }279+ sortEventsNewestFirst(rows.Events)280+ for _, row := range rows.Events {281+ if !t.gate.CanSee(ctx, row, session, nil) {282+ continue283+ }284+ for _, id := range event.TagValues(row, "e") {285+ resultKind, ok := wanted[id]286+ if ok && (row.Kind == event.KIND_JOB_FEEDBACK || row.Kind == resultKind) {287+ answers[id] = append(answers[id], row)288+ }289+ }290+ }291+ return answers, nil292+}293+294+// jobSettle fills the newest feedback, the newest result and the state of295+// one request from its answers, which arrive newest first.296+func jobSettle(item *jobItem, rows []event.Event) {297+ for _, row := range rows {298+ answer := jobAnswerFrom(row)299+ if row.Kind == event.KIND_JOB_FEEDBACK {300+ if item.Feedback == nil {301+ item.Feedback = &answer302+ item.Status = answer.Status303+ }304+ } else if item.Result == nil {305+ item.Result = &answer306+ }307+ }308+ item.State = "open"309+ if item.Result != nil || item.Status == "success" || item.Status == "error" {310+ item.State = "done"311+ }312+}313+314+func jobCursor(item jobItem) string {315+ return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID316+}@@ -0,0 +1,164 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strconv"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+// jobTenant returns a tenant with a member who requests jobs and an agent15+// granted to serve them.16+func jobTenant(t *testing.T) (*App, *Tenant, string, string) {17+ t.Helper()18+ app, tenant := testTenant(t)19+ ctx := context.Background()20+ owner := tenant.Policy().Owner21+ member, _ := event.PublicKey(testMemberSecret)22+ agent, _ := event.PublicKey(testAgentSecret)23+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {24+ t.Fatal(err)25+ }26+ now := time.Now().Unix()27+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"jobs", "serve"})); err != nil {28+ t.Fatalf("grant rejected: %v", err)29+ }30+ return app, tenant, member, agent31+}32+33+func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {34+ _, tenant, member, agent := jobTenant(t)35+ ctx := context.Background()36+ owner := tenant.Policy().Owner37+ now := time.Now().Unix()38+39+ open := wikiPublish(t, tenant, testMemberSecret, 5001, now-400, "", []string{"i", "hello", "text"}, []string{"i", strings.Repeat("d", 64), "event", "wss://relay.example", "context"}, []string{"output", "text/plain"}, []string{"bid", "1000"}, []string{"param", "lang", "es"}, []string{"relays", "wss://a.example", "wss://b.example"}, []string{"p", agent})40+ wikiPublish(t, tenant, testAgentSecret, 7000, now-350, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"})41+ working := wikiPublish(t, tenant, testAgentSecret, 7000, now-300, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "processing", "halfway"})42+ done := wikiPublish(t, tenant, testMemberSecret, 5002, now-390, "", []string{"i", "summarize", "text"})43+ wikiPublish(t, tenant, testAgentSecret, 7000, now-380, "", []string{"e", done.ID}, []string{"p", member}, []string{"status", "processing"})44+ result := wikiPublish(t, tenant, testAgentSecret, 6002, now-200, "the summary", []string{"e", done.ID}, []string{"p", member}, []string{"amount", "700"}, []string{"request", "{}"})45+ failed := wikiPublish(t, tenant, testOwnerSecret, 5001, now-300, "", []string{"i", "broken", "text"})46+ wikiPublish(t, tenant, testAgentSecret, 7000, now-250, "", []string{"e", failed.ID}, []string{"p", owner}, []string{"status", "error", "no such input"})47+ // An expired request is saved directly, as it would have been before it48+ // lapsed, and is no longer served.49+ expired := event.Event{Kind: 5001, CreatedAt: now - 7200, Content: "", Tags: [][]string{{"expiration", strconv.FormatInt(now-3600, 10)}}}50+ if err := event.Sign(&expired, testMemberSecret); err != nil {51+ t.Fatal(err)52+ }53+ if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {54+ t.Fatal(err)55+ }56+ note := wikiPublish(t, tenant, testMemberSecret, 1, now-100, "not a job")57+58+ listed, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{})59+ if err != nil {60+ t.Fatal(err)61+ }62+ items := wikiItems(listed, "items")63+ if len(items) != 3 || items[0]["id"] != failed.ID || items[1]["id"] != done.ID || items[2]["id"] != open.ID || listed["next_cursor"] != "" {64+ t.Fatalf("listed %v", items)65+ }66+ item := approvalByID(items, open.ID)67+ feedback, _ := item["feedback"].(map[string]any)68+ if item["state"] != "open" || item["status"] != "processing" || item["kind"] != 5001.0 || item["requester"] != member || item["output"] != "text/plain" || item["bid"] != "1000" || item["result"] != nil || feedback == nil || feedback["id"] != working.ID || feedback["info"] != "halfway" || feedback["provider"] != agent {69+ t.Fatalf("open item %v", item)70+ }71+ if inputs := item["inputs"].([]any); len(inputs) != 2 || inputs[0].(map[string]any)["type"] != "text" || inputs[1].(map[string]any)["relay"] != "wss://relay.example" || inputs[1].(map[string]any)["marker"] != "context" {72+ t.Fatalf("inputs %v", item["inputs"])73+ }74+ if params := item["params"].([]any); len(params) != 1 || params[0].(map[string]any)["key"] != "lang" || params[0].(map[string]any)["value"] != "es" {75+ t.Fatalf("params %v", item["params"])76+ }77+ if relays := item["relays"].([]any); len(relays) != 2 || relays[1] != "wss://b.example" {78+ t.Fatalf("relays %v", item["relays"])79+ }80+ if providers := item["providers"].([]any); len(providers) != 1 || providers[0] != agent {81+ t.Fatalf("providers %v", item["providers"])82+ }83+ item = approvalByID(items, done.ID)84+ answer, _ := item["result"].(map[string]any)85+ if item["state"] != "done" || item["status"] != "processing" || answer == nil || answer["id"] != result.ID || answer["kind"] != 6002.0 || answer["amount"] != "700" || answer["content"] != "the summary" {86+ t.Fatalf("done item %v", item)87+ }88+ item = approvalByID(items, failed.ID)89+ if item["state"] != "done" || item["status"] != "error" || item["result"] != nil {90+ t.Fatalf("failed item %v", item)91+ }92+93+ for state, want := range map[string][]string{"open": {open.ID}, "done": {failed.ID, done.ID}} {94+ filtered, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": state})95+ if err != nil {96+ t.Fatal(err)97+ }98+ got := []string{}99+ for _, item := range wikiItems(filtered, "items") {100+ got = append(got, item["id"].(string))101+ }102+ if strings.Join(got, ",") != strings.Join(want, ",") {103+ t.Fatalf("state %s lists %v", state, got)104+ }105+ }106+ mine, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"mine": true})107+ if err != nil {108+ t.Fatal(err)109+ }110+ if items := wikiItems(mine, "items"); len(items) != 2 || approvalByID(items, failed.ID) != nil {111+ t.Fatalf("mine %v", items)112+ }113+ if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {114+ t.Fatalf("bogus state %v", err)115+ }116+ if _, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"mine": true}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {117+ t.Fatalf("guest mine %v", err)118+ }119+ // Guests see the requests on an open relay; pages follow the cursor.120+ paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2})121+ if err != nil {122+ t.Fatal(err)123+ }124+ if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {125+ t.Fatalf("paged %v", paged)126+ }127+ rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})128+ if err != nil {129+ t.Fatal(err)130+ }131+ if items := wikiItems(rest, "items"); len(items) != 1 || items[0]["id"] != open.ID || rest["next_cursor"] != "" {132+ t.Fatalf("second page %v", rest)133+ }134+135+ detail, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": open.ID})136+ if err != nil {137+ t.Fatal(err)138+ }139+ timeline := wikiItems(detail, "feedback")140+ if item := detail["item"].(map[string]any); item["state"] != "open" || len(timeline) != 2 || timeline[0]["status"] != "payment-required" || timeline[0]["invoice"] != "lnbc1" || timeline[1]["id"] != working.ID || len(detail["results"].([]any)) != 0 {141+ t.Fatalf("open detail %v", detail)142+ }143+ detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": done.ID})144+ if err != nil {145+ t.Fatal(err)146+ }147+ if results := wikiItems(detail, "results"); len(results) != 1 || results[0]["id"] != result.ID || len(wikiItems(detail, "feedback")) != 1 {148+ t.Fatalf("done detail %v", detail)149+ }150+ for _, id := range []string{note.ID, expired.ID, strings.Repeat("f", 64)} {151+ if _, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": id}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {152+ t.Fatalf("browsejob %s: %v", id, err)153+ }154+ }155+ // Switching long tasks off closes the browse surface too.156+ policy := tenant.Policy()157+ policy.Features.Jobs = false158+ if err := tenant.applyPolicy(ctx, policy); err != nil {159+ t.Fatal(err)160+ }161+ if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{}); err == nil || !strings.Contains(err.Error(), "switched off") {162+ t.Fatalf("browse with jobs off %v", err)163+ }164+}@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browsejobs": {}, "browsejob": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
2626 return2727 }2828 }29- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseapprovals": true, "browseapproval": true}29+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseapprovals": true, "browseapproval": true, "browsejobs": true, "browsejob": true}3030 if !allowed[method] {3131 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})3232 return@@ -104,6 +104,11 @@
104104 (input, signal) => query("browseapprovals", input, signal));105105 register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.",106106 object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal));107+ register("tiny.list_jobs", "List long task requests (NIP-90 job requests) visible to your account, each with its newest feedback status and its result when one exists. state narrows the list to open, done or all; mine lists only your own requests.",108+ object({cursor: text, limit, state: {type: "string", enum: ["open", "done", "all"]}, mine: {type: "boolean"}}), reads,109+ (input, signal) => query("browsejobs", input, signal));110+ register("tiny.read_job", "Read one long task request by event id with its feedback timeline and results.",111+ object({id: hash}, ["id"]), reads, (input, signal) => query("browsejob", input, signal));107112 const pageName = {type: "string", minLength: 1, maxLength: 512, description: "Page name or title; the relay normalizes it to the NIP-54 d tag."};108113 register("tiny.list_wiki", "List wiki pages with their shown version, version count and open merge requests. Supports search by title or summary, an author filter and pagination.",109114 object({q: text, author: pubkey, cursor: text, limit}), reads, (input, signal) => query("browsewiki", input, signal));@@ -160,6 +160,32 @@ test("approval tools list, read and open requests for a decision", async () => {
160160 assert.equal(opened.searchParams.get("answer"), null);161161 });162162 163+test("job tools list and read long tasks through the session", async () => {164+ const requests = [];165+ const {tools} = await browser({path: "/r/work/tools", fetch: async url => {166+ requests.push(new URL(url, "https://tiny.example"));167+ return new Response(JSON.stringify({items: [{id: "a".repeat(64), kind: 5001, state: "open", status: "processing"}], next_cursor: "",168+ item: {id: "b".repeat(64), kind: 5002, state: "done"}, feedback: [{status: "processing"}], results: [{kind: 6002}]}));169+ }});170+ const list = tools.get("tiny.list_jobs");171+ assert.equal(list.annotations.readOnlyHint, true);172+ assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,all");173+ assert.equal(list.inputSchema.properties.mine.type, "boolean");174+ const listed = await list.execute({state: "open", mine: true, limit: 5});175+ assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");176+ assert.equal(requests.at(-1).searchParams.get("method"), "browsejobs");177+ assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", mine: true, limit: 5}]);178+ assert.equal(listed.structuredContent.result.items[0].status, "processing");179+ const read = tools.get("tiny.read_job");180+ assert.equal(read.annotations.readOnlyHint, true);181+ assert.deepEqual([...read.inputSchema.required], ["id"]);182+ const detail = await read.execute({id: "b".repeat(64)});183+ assert.equal(requests.at(-1).searchParams.get("method"), "browsejob");184+ assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);185+ assert.equal(detail.structuredContent.result.item.state, "done");186+ assert.equal(detail.structuredContent.result.results[0].kind, 6002);187+});188+163189 test("link, files and notification tools reach the new surfaces", async () => {164190 const calls = [];165191 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {