clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

browse long tasks with their feedback and results

Dami committed ee0004f
commit
ee0004f7e60c8b464c09d2bc6f0d5969a6f14c34
parent
e5a69f652e8317892aa6863a1d16fb2823fa9909
author
Dami <dami.etoile@gmail.com>
7 files changed, +517 -3
Minternal/daemon/client_browse.go5+-
Ainternal/daemon/jobs_browse.go316++++++++++++++++++++++++++++++
Ainternal/daemon/jobs_test.go164++++++++++++++++
Minternal/telemetry/telemetry.go2+-
Minternal/webui/webmcp.go2+-
Minternal/webui/webmcp.js5+
Mscripts/webmcp.test.mjs26++
M internal/daemon/client_browse.go +4 -1
A internal/daemon/jobs_browse.go +316 -0
@@ -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+}
A internal/daemon/jobs_test.go +164 -0
@@ -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+}
M internal/telemetry/telemetry.go +1 -1
@@ -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": {}}
M internal/webui/webmcp.go +1 -1
M internal/webui/webmcp.js +5 -0
M scripts/webmcp.test.mjs +26 -0
@@ -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) => {