| 1 | diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go |
| 2 | index b5b51b4..3b16d5c 100644 |
| 3 | --- a/internal/daemon/jobs_test.go |
| 4 | +++ b/internal/daemon/jobs_test.go |
| 5 | @@ -169,6 +169,38 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) { |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | +func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) { |
| 10 | + _, tenant, member, agent := jobTenant(t) |
| 11 | + ctx := context.Background() |
| 12 | + now := time.Now().Unix() |
| 13 | + request := wikiPublish(t, tenant, testMemberSecret, 5001, now-100, "", []string{"i", "hello", "text"}) |
| 14 | + result := event.Event{Kind: 6001, PubKey: agent, CreatedAt: now, Tags: [][]string{{"e", request.ID}, {"p", member}}, Content: "hola"} |
| 15 | + notices := tenant.pushNotices(ctx, result) |
| 16 | + if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != member || notices[0].body != "job 5001 result" || !strings.HasSuffix(notices[0].url, "/e/"+result.ID) || len(notices[0].actions) != 0 { |
| 17 | + t.Fatalf("result notices %+v", notices) |
| 18 | + } |
| 19 | + for status, want := range map[string]int{"error": 1, "payment-required": 1, "processing": 0, "success": 0, "partial": 0} { |
| 20 | + feedback := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", status}, {"e", request.ID}, {"p", member}}} |
| 21 | + notices := tenant.pushNotices(ctx, feedback) |
| 22 | + if len(notices) != want { |
| 23 | + t.Fatalf("%s feedback notices %+v", status, notices) |
| 24 | + } |
| 25 | + if want == 1 && (notices[0].category != pushMentions || notices[0].body != "job 5001 "+status) { |
| 26 | + t.Fatalf("%s feedback notice %+v", status, notices[0]) |
| 27 | + } |
| 28 | + } |
| 29 | + // Feedback for a request the relay does not hold names its own kind. |
| 30 | + elsewhere := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", strings.Repeat("e", 64)}, {"p", member}}} |
| 31 | + if notices := tenant.pushNotices(ctx, elsewhere); len(notices) != 1 || notices[0].body != "job 7000 error" { |
| 32 | + t.Fatalf("elsewhere notices %+v", notices) |
| 33 | + } |
| 34 | + // The requester's own feedback wakes nobody. |
| 35 | + own := event.Event{Kind: 7000, PubKey: member, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", request.ID}, {"p", member}}} |
| 36 | + if notices := tenant.pushNotices(ctx, own); len(notices) != 0 { |
| 37 | + t.Fatalf("own notices %+v", notices) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) { |
| 42 | _, tenant, member, agent := jobTenant(t) |
| 43 | ctx := context.Background() |
| 44 | diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go |
| 45 | index db8dec2..f8c8e16 100644 |
| 46 | --- a/internal/daemon/webpush_triggers.go |
| 47 | +++ b/internal/daemon/webpush_triggers.go |
| 48 | @@ -91,6 +91,15 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice { |
| 49 | if kind, ok := approvalRequest(e); ok { |
| 50 | return t.approvalNotices(e, kind) |
| 51 | } |
| 52 | + // A job result, or feedback that needs the requester's attention, |
| 53 | + // reaches the requester as a mention. |
| 54 | + if status, ok := jobNotice(e); ok { |
| 55 | + body := "job " + t.jobRequestKind(ctx, e) + " " + status |
| 56 | + for _, recipient := range pushRecipients(e) { |
| 57 | + notices = append(notices, pushNotice{recipient: recipient, category: pushMentions, body: body, url: base + "/e/" + e.ID}) |
| 58 | + } |
| 59 | + return notices |
| 60 | + } |
| 61 | switch e.Kind { |
| 62 | case event.KIND_WRAP, 4: |
| 63 | for _, recipient := range pushRecipients(e) { |
| 64 | @@ -146,6 +155,38 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice { |
| 65 | return notices |
| 66 | } |
| 67 | |
| 68 | +// jobNotice reports whether a long task event wakes its requester and with |
| 69 | +// which status: every result does, and feedback does when it asks for |
| 70 | +// payment or reports an error. Progress and success feedback stay quiet, |
| 71 | +// since the result follows. |
| 72 | +func jobNotice(e event.Event) (string, bool) { |
| 73 | + if event.IsJobResult(e.Kind) { |
| 74 | + return "result", true |
| 75 | + } |
| 76 | + if e.Kind != event.KIND_JOB_FEEDBACK { |
| 77 | + return "", false |
| 78 | + } |
| 79 | + status := event.Tag(e, "status") |
| 80 | + return status, status == "error" || status == "payment-required" |
| 81 | +} |
| 82 | + |
| 83 | +// jobRequestKind names the request kind an answer belongs to: a result |
| 84 | +// says it by its own kind, and feedback by the request the relay holds. |
| 85 | +func (t *Tenant) jobRequestKind(ctx context.Context, e event.Event) string { |
| 86 | + if event.IsJobResult(e.Kind) { |
| 87 | + return strconv.Itoa(e.Kind - 1000) |
| 88 | + } |
| 89 | + id := event.Tag(e, "e") |
| 90 | + if len(id) != 64 { |
| 91 | + return strconv.Itoa(e.Kind) |
| 92 | + } |
| 93 | + rows, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 94 | + if err != nil || len(rows.Events) == 0 || !event.IsJobRequest(rows.Events[0].Kind) { |
| 95 | + return strconv.Itoa(e.Kind) |
| 96 | + } |
| 97 | + return strconv.Itoa(rows.Events[0].Kind) |
| 98 | +} |
| 99 | + |
| 100 | // pushRecipients lists the addressed keys other than the author, bounded so |
| 101 | // a broadcast to a large list cannot fan out to every device. |
| 102 | func pushRecipients(e event.Event) []string { |