clone

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

feat: carry long tasks on the job kinds Buzz reserves instead of NIP-90

Dami committed fc78fb9
NIP-90 is marked unrecommended upstream. Long tasks now use kinds
43001 to 43006 (request, accepted, progress, result, cancel, error)
with Tinyrelay's own tag contract: a request names its room and the
keys asked, every answer names the request and the requester and stays
in the request's room, and only asked keys may answer. Task cards,
browsejobs and browsejob, push notices, the MCP tools (request_job,
accept_job, job_progress, job_result, job_error, cancel_job), WebMCP,
the agent runner, the QA fixtures and the docs follow the new shape.
Agent grants keep the jobs tag with request, serve and both.
commit
fc78fb98e165ce877adcac1af1537ad73060d2ed
parent
242a31ff4c0fe2c17967727d91dd85f709863600
author
Dami <dami.etoile@gmail.com>
29 files changed, +1011 -808
Mdocs/agents.md79+++++--
Mdocs/extensions/agent-grants.md2+-
Mdocs/mcp.md19+-
Mdocs/webmcp.md2+-
Minternal/agentrunner/service.go23+-
Minternal/community/agents.go4+-
Minternal/daemon/chat_activity.go2+-
Minternal/daemon/chat_activity_test.go15+-
Minternal/daemon/jobs_browse.go251++++++++++----------
Minternal/daemon/jobs_test.go355++++++++++++++++-------------
Minternal/daemon/mcp_http.go6+-
Minternal/daemon/mcp_tools.go374+++++++++++++++---------------
Minternal/daemon/site_snapshot_jobs_test.go12+-
Minternal/daemon/webpush_triggers.go46++--
Minternal/event/kinds.go76++----
Minternal/event/kinds_test.go32+-
Minternal/gates/gates.go8+-
Minternal/gates/jobs.go165++++++++-----
Minternal/gates/jobs_test.go187++++++++++-----
Minternal/policy/policy.go5+-
Mscripts/qa/buzz-chat-fixture.mjs4+-
Mscripts/qa/buzz-runner-integration.mjs20+-
Mscripts/webmcp.test.mjs10+-
Mtinyclient/chat-activity.go72++++--
Mtinyclient/chat-activity.html2+-
Mtinyclient/chat_activity_http_test.go5+-
Mtinyclient/chat_activity_test.go35+++-
Mtinyclient/remote_rooms_test.go2+-
Mtinyclient/webmcp.js6+-
M docs/agents.md +60 -19
@@ -59,7 +59,7 @@ Every event from an agent key passes these checks before it is stored, whether i
5959 - The event kind appears in the grant's `k` tags, or the grant's `wiki`, `jobs` or `sites` tag covers it. Profiles (kind 0) and relay lists (kind 10002) are always allowed.6060 - A site manifest names a site the grant's `sites` tags cover and, when the covering entry sets a ttl, expires within it. See [Static sites](#static-sites).6161 - A wiki version from an agent with `wiki: propose` is stored as a proposal: it is shown only to the owner, moderators and the agent until the owner or a moderator approves it with a `+` reaction to that version, and every new version needs its own approval. A `-` reaction rejects it. With `wiki: edit`, versions show at once.62-- A job result or job feedback names a request the relay holds and the agent may read, and matches that request's kind and author.62+- A job answer names a request the relay holds that asked the agent, and a job cancel a request the agent made. See [Long tasks](#long-tasks).6363 - If the event carries an `h` tag, the room appears in the grant's `room` tags.6464 - Repository events name a repository the grant covers. Issues, patches, pull requests and comments need `propose`, `read` or `maintain`. Status changes (kinds 1630 to 1633) need `maintain`.6565 - An issue, pull request, patch or comment from an agent with `propose` on that repository is stored as a proposal: it is shown only to the repository owner, its maintainers, the relay owner, moderators and the agent until one of them approves it with a `+` reaction to that event, and a resubmitted event needs its own approval. A `-` reaction rejects it. With `read`, the same events show at once.
@@ -166,36 +166,76 @@ Agents read the same information with the `browseapprovals` and `browseapproval`
166166 167167 ## Long tasks168168 169-A long task is work that takes longer than one exchange: a transcription, a summary, a build. The relay carries it with the [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) events, so every step is a signed event from the key that took it, and no one has to hold a connection open while the work runs.169+A long task is work that takes longer than one exchange: a transcription, a summary, a build. The relay carries it with six job events, the kinds [Buzz](https://github.com/block/buzz) reserves for jobs, so every step is a signed event from the key that took it, and no one has to hold a connection open while the work runs.170+171+| Kind | Event | Published by |172+| --- | --- | --- |173+| 43001 | Job request | The requester |174+| 43002 | Job accepted | A key the request asked |175+| 43003 | Job progress, as often as needed | A key the request asked |176+| 43004 | Job result, which ends the task | A key the request asked |177+| 43005 | Job cancel, which ends the task | The requester |178+| 43006 | Job error, which ends the task | A key the request asked |170179 171180 ### The flow172181 173-1. A person or an agent publishes a job request, an event of kind 5000 to 5127 or 5129 to 5999. The kind names the type of work. The request may carry `i` tags for its inputs, an `output` tag for the expected MIME type, `param` tags, a `bid` in millisats, `relays` where answers should go, `p` tags for the providers it prefers and an `expiration`.174-2. A serving agent answers with job feedback, kind 7000, as often as it likes. Feedback names the request in an `e` tag and the requester in a `p` tag, and carries a `status` tag of `payment-required`, `processing`, `error`, `success` or `partial`, with optional extra text, an `amount` in millisats with an optional invoice, and a sample of the output in the content.175-3. When the work is done, the agent publishes the result, an event of the request kind plus 1000. It names the request and the requester the same way, carries the request as JSON in a `request` tag with the request's inputs, and holds the output in its content.182+1. A person or an agent publishes a job request in a room, naming the keys it asks to do the work. The request may carry a short subject, attach to a thread and expire.183+2. Each key asked answers as it works: accepted, then progress lines as often as it likes.184+3. The work ends with a result, an error or a cancel from the requester. A result may reference what it produced: an event, a wiki page or a pull request, or a URL.185+186+Both sides sign their own events, so the request is attributable to whoever asked and every answer to the key that did the work.187+188+### The tags189+190+A request, kind 43001, carries:191+192+| Tag | Required | Meaning |193+| --- | --- | --- |194+| `h` | Yes | The room the task belongs to. Room access rules apply as for a kind 9 message. |195+| `p` | One or more | The keys asked to do the work. |196+| `subject` | No | A short title, shown on the task card. |197+| `e` | No | `["e", "<root id>", "", "root"]` attaches the task to a thread in the same room. |198+| `expiration` | No | A Unix time after which the request lapses. |199+200+The content holds the task text. It may be empty when there is a subject.201+202+An answer, kinds 43002, 43003, 43004 and 43006, carries:203+204+| Tag | Required | Meaning |205+| --- | --- | --- |206+| `e` | Yes | The request being answered. |207+| `p` | Yes | The requester: the request's author. |208+| `h` | Yes | The request's room. |209+| `e`, `a`, `r` | No | On a result, further references to artifacts: an event id, an addressable event coordinate or an http or https URL. Clients link them. |176210 177-Both sides sign their own events, so the request is attributable to whoever asked and every answer to the agent that did the work. A requester cancels with a kind 5 deletion of the request.211+The content holds the progress line, the output or the error message. It may be empty on an accepted event.212+213+A cancel, kind 43005, carries `e` (the request), `h` (the room) and optionally `p` (one key asked). Only the request's author may cancel.178214 179215 ### What the relay enforces180216 181-- Job requests, results and feedback are accepted from members and agents while the policy's `features.jobs` switch is on. It is on by default; the owner turns it off with `setpolicy` and `{"features": {"jobs": false}}`, which also closes the job queries and tools.182-- Results and feedback must name the request in `e` and the requester in `p`. Feedback must carry one of the five statuses. When the relay holds the request, the result kind must be the request kind plus 1000 and `p` must name the request's author.183-- An agent's result or feedback must name a request the relay holds and the agent may read. A member may also answer a request made on another relay.217+- Job requests, answers and cancels are accepted from members and agents while the policy's `features.jobs` switch is on. It is on by default; the owner turns it off with `setpolicy` and `{"features": {"jobs": false}}`, which also closes the job queries and tools.218+- A request must name a room in `h` and at least one asked key in `p`.219+- An answer or a cancel must name a request the relay holds and the publisher may read. An answer must come from a key the request asked, name the requester in `p` and keep the request's room in `h`. A cancel must come from the requester and keep the room.184220 - An `expiration` tag on a request works as it does everywhere else: the relay refuses an expired event and stops serving a request once it lapses.185221 186-Malformed events are refused with an `invalid:` reason that names the missing or wrong tag.222+Malformed events are refused with an `invalid:` reason that names the missing or wrong tag; an answer from the wrong key with a `restricted:` reason.223+224+### The state of a task225+226+Readers derive a task's state from its answers. With no answers, the task is `open` and `queued`. The newest of the ending events wins: a result makes the task `done`, a cancel `cancelled` and an error `failed`. Otherwise the newest accepted or progress answer makes the status `accepted` or `running`, and the task stays `open`.187227 188228 ### The grant189229 190-An agent takes part through its grant. A `k` tag admits one kind, as for any other event. The `jobs` tag admits the ranges:230+An agent takes part through its grant. A `k` tag admits one kind, as for any other event, and the grant must include the room. The `jobs` tag admits the kinds by role:191231 192232 | Value | Lets the agent publish |193233 | --- | --- |194-| `request` | Job requests, kinds 5000 to 5127 or 5129 to 5999. |195-| `serve` | Job results, kinds 6000 to 6999, and job feedback, kind 7000, in answer to requests the relay holds. |234+| `request` | Job requests and cancels, kinds 43001 and 43005. |235+| `serve` | Job accepted, progress, result and error events, kinds 43002, 43003, 43004 and 43006, in answer to requests that asked the agent. |196236 | `both` | Both. |197237 198-Example grant for an agent that transcribes audio and summarizes text:238+Example grant for an agent that summarizes and transcribes in the `work` room:199239 200240 ```json201241 {
@@ -204,6 +244,7 @@ Example grant for an agent that transcribes audio and summarizes text:
204244 ["d", "<agent pubkey>"],205245 ["p", "<agent pubkey>"],206246 ["name", "scribe"],247+ ["room", "work"],207248 ["expiration", "1735689600"],208249 ["jobs", "serve"]209250 ],
@@ -213,11 +254,11 @@ Example grant for an agent that transcribes audio and summarizes text:
213254 214255 ### Reading and writing215256 216-The `browsejobs` query lists the requests the caller may see with each one's newest feedback status and its result when one exists. `state` narrows the list to `open`, `done` (a result exists, or the newest feedback reports `success` or `error`) or `all`, and `mine` lists only the caller's own requests. `browsejob` returns one request with its feedback timeline and results. In the browser these are `tiny.list_jobs` and `tiny.read_job`; see [Browser tools](webmcp.md#long-tasks).257+The `browsejobs` query lists the requests the caller may see, each with its room, requester, assignees, subject, state, status, newest progress, result with its artifacts, error and cancel time. `state` narrows the list to `open`, `done`, `failed`, `cancelled` or `all`, and `mine` lists only the caller's own requests. `browsejob` returns one request with its answers, oldest first. In the browser these are `tiny.list_jobs` and `tiny.read_job`; see [Browser tools](webmcp.md#long-tasks).217258 218-Over MCP, `request_job` builds a request, `job_feedback` and `job_result` build the answers, and `list_jobs` and `read_job` read them. Each write tool returns the unsigned event for the caller to sign and publishes it when called again with the signed event. See [MCP](mcp.md#long-tasks).259+Over MCP, `request_job` builds a request, `accept_job`, `job_progress`, `job_result` and `job_error` build the answers, `cancel_job` builds a cancel, and `list_jobs` and `read_job` read them. Each write tool returns the unsigned event for the caller to sign and publishes it when called again with the signed event. See [MCP](mcp.md#long-tasks).219260 220-A result, or feedback that reports `error` or `payment-required`, wakes the requester's devices in the mentions category with the body `job <kind> <status>`, where the kind is the request's.261+A result or an error wakes the requester's devices in the mentions category with the body `task <subject> done` or `task <subject> failed`, where a request without a subject is named by the first characters of its id.221262 222263 ## Agent chat features223264
@@ -225,9 +266,9 @@ Agents can participate in room conversations using the same Nostr events as peop
225266 226267 An agent may publish ephemeral kind 20001 presence and kind 20002 typing events when its grant includes those kinds. These events are delivered live to authorized room members and are never written to history. They are useful for showing that an agent is working, but they are not a job record or a durable status. See Buzz's [Nostr event conventions](https://github.com/block/buzz/blob/main/NOSTR.md) for a compatible client reference.227268 228-NIP-90 job requests, feedback and results appear as compact task cards above the room conversation. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**. A task card can show current progress, a result or an error without changing the underlying event protocol.269+Job requests appear as compact task cards above the room conversation, with the newest progress line, the result and its artifact links, the error or the cancel. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**.229270 230-The relay follows standard NIP-90 events for long tasks. Buzz's experimental 43001 to 43006 job kinds are not required for room chat or task cards; clients can continue to use their own support for those kinds when they need it.271+The relay uses the same six job kinds, 43001 to 43006, that Buzz reserves for long tasks, so a Buzz client and the relay's own pages read the same events. Buzz leaves the tags open; the relay's tags are listed under [Long tasks](#long-tasks).231272 232273 ## Run a local agent233274
M docs/extensions/agent-grants.md +1 -1
@@ -54,7 +54,7 @@ Pause/resume are local management state, not signed grant replacements or portab
5454 5555 - Profiles (`0`), relay lists (`10002`) and authentication (`22242`) pass the grant's kind test. An inactive grant still fails the state test. Other kinds require a `k` entry or the derived permission below.5656 - `wiki=propose` admits `30818` and `818`; `wiki=edit` adds `30819`. Proposal visibility and decisions belong to the [wiki approval integration](../../internal/daemon/wiki_approvals.go), not event signature validation.57-- `jobs=request` admits requests `5000` through `5999`, excluding reserved kind `5128`; `serve` admits results `6000` through `6999` and feedback `7000`; `both` combines them. Agent answers require a locally held, readable request and valid request references. See [`jobs_test.go`](../../internal/gates/jobs_test.go).57+- `jobs=request` admits job requests `43001` and cancels `43005`; `serve` admits job accepted `43002`, progress `43003`, result `43004` and error `43006` events; `both` combines them. An answer requires a locally held, readable request that asked the agent, and a cancel a request the agent made. See [`jobs_test.go`](../../internal/gates/jobs_test.go).5858 - `sites` admits `15128` and `35128`, not snapshot kind `5128`. A manifest must match a covering label even if its kind was explicitly granted. When every covering entry has a TTL, its `expiration` must fit the longest covering TTL measured from admission time; an entry with no TTL removes that bound.5959 - A nonempty first `h` value must be in the grant's rooms. This does not bypass room existence, membership or administration rules.6060 - Repository collaboration kinds `1617`, `1618`, `1619`, `1621`, `1111` and `1630` through `1633` are checked against valid `30617:<owner>:<identifier>` coordinates in `a` and `A` tags. Every recognized coordinate needs a grant entry. All except a comment (`1111`) require such a coordinate. Status kinds require `maintain`; all still require kind permission.
M docs/mcp.md +12 -7
@@ -68,8 +68,8 @@ Read tools, which need a key that may read the relay:
6868 | `read_wiki_page` | Read a page by `d` with its versions, its `history` of revisions with their states, merge requests and redirects. `author` or `version` selects the version shown; `version` also opens an archived revision from the history. |6969 | `read_merge_request` | Read a wiki merge request by `id` with its status, proposed version and target version. |7070 | `list_agents` | List granted agents with their state, scope and last event, as an owner or moderator. |71-| `list_jobs` | List long task requests visible to the key with each one's newest feedback status and result. `state` narrows the list to `open`, `done` or `all`; `mine` lists only the key's own requests. |72-| `read_job` | Read one long task request by `id` with its feedback timeline and results. |71+| `list_jobs` | List long task requests visible to the key with each one's state, status, newest progress, result and error. `state` narrows the list to `open`, `done`, `failed`, `cancelled` or `all`; `mine` lists only the key's own requests. |72+| `read_job` | Read one long task request by `id` with its answers, oldest first. |7373 | `list_callbacks` | List event callbacks with their host, filter, state and last delivery. Members and agents see their own; the owner and moderators see all. |7474 | `list_join_requests` | List access requests from people who asked to join without an invite, pending first, with each one's key, reason, time, state and decision, as an owner or moderator. See [Access requests](membership.md#access-requests). |7575
@@ -117,9 +117,12 @@ Write tools, which publish through the same path as `POST /events`:
117117 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |118118 | `request_decision` | Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a `request` tag. |119119 | `request_grant` | Ask the current grant operator for additional agent permissions with a reason of up to 500 Unicode characters and at least one change. Returns an unsigned kind 1111 NIP-22 request for the agent to sign; the operator must review and publish the replacement kind 30392 grant. |120-| `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5127 or 5129 to 5999 carrying its inputs, output type, parameters, bid and relays. |121-| `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |122-| `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |120+| `request_job` | Ask one or more keys to take on a long task with a kind 43001 job request in a room, with an optional subject, thread root and expiration. |121+| `accept_job` | Take on a long task you were asked to do with a kind 43002 event. |122+| `job_progress` | Report progress on a long task with a kind 43003 event, as often as needed. |123+| `job_result` | Finish a long task with a kind 43004 event carrying the output and optional artifact references. |124+| `job_error` | End a long task that failed with a kind 43006 event carrying the error message. |125+| `cancel_job` | Cancel a long task you requested with a kind 43005 event. |123126 124127 The relay never signs on a caller's behalf. Call a write tool with plain fields, such as `owner`, `repo`, `title` and `content`, and it returns the unsigned event to sign. Call it again with the signed event as `event` and the relay checks the kind and tags before publishing. A malformed event is refused with a message that lists the expected tags.125128
@@ -171,9 +174,11 @@ Room uploads use `/media/<hash>.<extension>` URLs compatible with Buzz. Reads fo
171174 172175 ### Long tasks173176 174-`request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5127 or 5129 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag.177+`request_job` builds a kind 43001 job request. Pass `room`, `assignees` (the keys asked, each becoming a `p` tag) and `content` or `subject`, plus optional `root` (a thread root in the room) and `expiration`.175178 176-A serving agent answers with `job_feedback`, which takes `e` (the request id), `p` (the requester) and `status` (`payment-required`, `processing`, `error`, `success` or `partial`) plus optional `info`, `amount`, `invoice` and `content`, and then with `job_result`, which takes the `request` event and `content` plus optional `amount` and `invoice`. The result's kind is the request kind plus 1000 and carries the request as JSON in its `request` tag with the request's inputs. The relay accepts feedback and results only when they name the request and the requester; from an agent, only when the relay holds the request. `list_jobs` and `read_job` follow the work. See [Long tasks](agents.md#long-tasks).179+A key the request asked answers with `accept_job`, `job_progress`, `job_result` or `job_error`. Each takes `request` (the job request event) or `e` (the request id), `p` (the requester) and `room`, plus `content`: a progress line, the output or the error message. `job_result` also takes `artifacts`, each with a `type` of `e` (an event id), `a` (an addressable event coordinate) or `r` (an http or https URL) and its `value`; each becomes a tag of that name. The requester withdraws a task with `cancel_job`, which takes `request` or `e` and `room`, plus optional `p` (one key asked) and `content`.180+181+The relay accepts an answer only from a key the request asked, and only for a request it holds; a cancel only from the requester. `list_jobs` and `read_job` follow the work. See [Long tasks](agents.md#long-tasks).177182 178183 ## Example179184
M docs/webmcp.md +1 -1
@@ -20,7 +20,7 @@ Three tools cover requests for a decision. `tiny.list_approvals` lists the reque
2020 2121 ## Long tasks2222 23-Two tools follow [long tasks](agents.md#long-tasks). `tiny.list_jobs` lists the job requests visible to the signed-in account with each one's kind, requester, inputs, newest feedback status and result; `state` narrows the list to `open`, `done` or `all`, and `mine` lists only your own requests. `tiny.read_job` returns one request by event id with its feedback timeline and results. Requesting and answering stay signed actions through the relay's [MCP](mcp.md#long-tasks) tools or a relay connection.23+Two tools follow [long tasks](agents.md#long-tasks). `tiny.list_jobs` lists the job requests visible to the signed-in account with each one's room, requester, assignees, subject, state, status, newest progress, result with its artifacts, error and cancel time; `state` narrows the list to `open`, `done`, `failed`, `cancelled` or `all`, and `mine` lists only your own requests. `tiny.read_job` returns one request by event id with its answers, oldest first. Requesting, answering and cancelling stay signed actions through the relay's [MCP](mcp.md#long-tasks) tools or a relay connection.2424 2525 Choose **Sign in** in the footer, or follow a sign-in link on a protected page, and connect your Nostr signer. Reading protected information uses your browser session. Management actions request a signature and follow your account permissions. The Account page holds the signed-in key's profile, relay lists and Chat presence preference; use the page directly because there is no browser-agent mutation tool for those personal settings. A queued backup or job has not finished until its status says so.2626
M internal/agentrunner/service.go +10 -13
@@ -2,7 +2,6 @@ package agentrunner
22 33 import (44 "context"5- "encoding/json"65 "errors"76 "fmt"87 "slices"
@@ -91,22 +90,23 @@ func (s *service) handle(parent context.Context, batch []Mention) error {
9190 // Keep the canonical root on the batch so permission requests emitted9291 // while this task runs stay attached to the same top-level conversation.9392 batch[0].Root = root94- tags := [][]string{{"h", first.Room}, {"e", first.Root}, {"p", s.relay.PubKey}, {"output", "text/plain"}, {"subject", shortText(first.Prompt, 100)}, {"expiration", strconv.FormatInt(time.Now().Add(s.options.Timeout).Unix(), 10)}}93+ // The runner records its work as a long task it assigns to itself, so94+ // the room's task card follows progress, the result or the error.95+ tags := [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}, {"p", s.relay.PubKey}, {"subject", shortText(first.Prompt, 100)}, {"expiration", strconv.FormatInt(time.Now().Add(s.options.Timeout).Unix(), 10)}}9596 var prompt strings.Builder9697 prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n")9798 for _, m := range batch {98- tags = append(tags, []string{"i", m.EventID, "event"})9999 fmt.Fprintf(&prompt, "Author: %s\nMessage: %s\n%s\n\n", m.Author, m.EventID, m.Prompt)100100 }101- request, err := s.relay.Publish(ctx, event.Event{Kind: 5000, CreatedAt: first.CreatedAt, Tags: tags, Content: shortText(first.Prompt, 1000)})101+ request, err := s.relay.Publish(ctx, event.Event{Kind: event.KIND_JOB_REQUEST, CreatedAt: first.CreatedAt, Tags: tags, Content: shortText(first.Prompt, 1000)})102102 if err != nil {103103 return err104104 }105- feedback := func(status, info string) error {106- _, err := s.relay.Publish(ctx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", status, shortText(info, 500)}}, Content: shortText(info, 4000)})105+ answer := func(ctx context.Context, kind int, text string) error {106+ _, err := s.relay.Publish(ctx, event.Event{Kind: kind, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}}, Content: shortText(text, 4000)})107107 return err108108 }109- if err := feedback("processing", "Working on your request"); err != nil {109+ if err := answer(ctx, event.KIND_JOB_PROGRESS, "Working on your request"); err != nil {110110 return err111111 }112112 process := s.options.Process
@@ -116,7 +116,7 @@ func (s *service) handle(parent context.Context, batch []Mention) error {
116116 return117117 }118118 last = time.Now()119- if err := feedback("processing", info); err != nil {119+ if err := answer(ctx, event.KIND_JOB_PROGRESS, info); err != nil {120120 cancel()121121 }122122 }
@@ -137,8 +137,7 @@ func (s *service) handle(parent context.Context, batch []Mention) error {
137137 if errors.Is(runErr, context.Canceled) {138138 message = "Canceled"139139 }140- _, publishErr := s.relay.Publish(finishCtx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", "error"}}, Content: shortText(message, 1000)})141- if publishErr != nil {140+ if publishErr := answer(finishCtx, event.KIND_JOB_ERROR, shortText(message, 1000)); publishErr != nil {142141 return errors.Join(runErr, publishErr)143142 }144143 return runErr
@@ -147,9 +146,7 @@ func (s *service) handle(parent context.Context, batch []Mention) error {
147146 if output == "" {148147 output = "Task completed."149148 }150- raw, _ := json.Marshal(request)151- _, err = s.relay.Publish(ctx, event.Event{Kind: 6000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"request", string(raw)}}, Content: output})152- if err != nil {149+ if err := answer(ctx, event.KIND_JOB_RESULT, output); err != nil {153150 return err154151 }155152 replyTags := [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}}
M internal/community/agents.go +2 -2
M internal/daemon/chat_activity.go +1 -1
M internal/daemon/chat_activity_test.go +9 -6
@@ -8,8 +8,8 @@ import (
88 99 func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {1010 h := newRoomHarness(t)11- request := h.must("alice", 5000, [][]string{{"h", "main"}, {"p", h.keys["bob"]}, {"subject", "Build preview"}}, "Build preview")12- h.must("bob", 7000, [][]string{{"h", "main"}, {"e", request.ID}, {"p", h.keys["alice"]}, {"status", "processing", "Running checks"}}, "2 of 3 checks complete")11+ request := h.must("alice", event.KIND_JOB_REQUEST, [][]string{{"h", "main"}, {"p", h.keys["bob"]}, {"subject", "Build preview"}}, "Build preview")12+ h.must("bob", event.KIND_JOB_PROGRESS, [][]string{{"h", "main"}, {"e", request.ID}, {"p", h.keys["alice"]}}, "2 of 3 checks complete")1313 decision := h.must("alice", 9, [][]string{{"h", "main"}, {"request", "approve"}, {"p", h.keys["bob"]}, {"subject", "Publish preview"}}, "Publish it?")1414 read := func(who, room string) map[string]any {1515 t.Helper()
@@ -21,7 +21,7 @@ func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {
2121 }2222 data := read("bob", "main")2323 jobs := data["jobs"].([]jobItem)24- if len(jobs) != 1 || jobs[0].Status != "processing" {24+ if len(jobs) != 1 || jobs[0].Status != "running" || jobs[0].Progress == nil || jobs[0].Progress.Content != "2 of 3 checks complete" {2525 t.Fatalf("jobs=%v", jobs)2626 }2727 approvals := data["approvals"].([]approvalItem)
@@ -41,7 +41,7 @@ func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {
4141 t.Fatal("Approvals disagrees")4242 }4343 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"name", "Private"}, {"visibility", "members"}}, "")44- private := h.must("alice", 5000, [][]string{{"h", "private-chat"}, {"p", h.keys["alice"]}}, "Private work")44+ private := h.must("alice", event.KIND_JOB_REQUEST, [][]string{{"h", "private-chat"}, {"p", h.keys["alice"]}}, "Private work")4545 if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "private-chat"})}); err == nil {4646 t.Fatal("outsider read private activity")4747 }
@@ -50,9 +50,12 @@ func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {
5050 t.Fatal("private job leaked")5151 }5252 }53- if _, err := h.publish("alice", 7000, [][]string{{"e", private.ID}, {"p", h.keys["alice"]}, {"status", "processing"}}, "private progress"); err == nil {53+ if _, err := h.publish("alice", event.KIND_JOB_PROGRESS, [][]string{{"e", private.ID}, {"p", h.keys["alice"]}}, "private progress"); err == nil {5454 t.Fatal("unscoped progress accepted for a private room task")5555 }56+ if _, err := h.publish("alice", event.KIND_JOB_PROGRESS, [][]string{{"h", "main"}, {"e", private.ID}, {"p", h.keys["alice"]}}, "private progress"); err == nil {57+ t.Fatal("progress in another room accepted for a private room task")58+ }5659 }5760 func TestChatPresenceIsEphemeralAndRoomAuthorized(t *testing.T) {5861 h := newRoomHarness(t)
@@ -73,7 +76,7 @@ func TestChatActivityIncludesParentOnlyRequestsInCanonicalThread(t *testing.T) {
7376 root := h.must("alice", 9, [][]string{{"h", "main"}}, "Root")7477 parent := h.must("bob", 9, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "Parent")7578 approval := h.must("alice", 9, [][]string{{"h", "main"}, {"e", parent.ID, "", "reply"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Approve the nested action?")76- job := h.must("alice", 5000, [][]string{{"h", "main"}, {"e", parent.ID}, {"p", h.keys["bob"]}}, "Nested job")79+ job := h.must("alice", event.KIND_JOB_REQUEST, [][]string{{"h", "main"}, {"e", parent.ID, "", "root"}, {"p", h.keys["bob"]}}, "Nested job")7780 other := h.must("alice", 9, [][]string{{"h", "main"}}, "Another root")7881 h.must("alice", 9, [][]string{{"h", "main"}, {"e", other.ID, "", "root"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Unrelated action")7982 raw, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "main", "root": root.ID})})
M internal/daemon/jobs_browse.go +126 -125
@@ -1,9 +1,9 @@
11 package daemon22 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.3+// Long task browsing: the job requests the caller may see, each settled4+// against its answers, and one request with its answer timeline. Both read5+// through the tenant gate with the caller's session, like the other browse6+// methods.77 88 import (99 "context"
@@ -11,9 +11,11 @@ import (
1111 "errors"1212 "fmt"1313 "strconv"14+ "strings"1415 "time"1516 1617 "github.com/FelineStateMachine/tinyrelay/internal/event"18+ "github.com/FelineStateMachine/tinyrelay/internal/gates"1719 "github.com/FelineStateMachine/tinyrelay/internal/relay"1820 "github.com/FelineStateMachine/tinyrelay/internal/storage"1921 )
@@ -21,18 +23,15 @@ import (
2123 // jobScan bounds how many stored requests or answers one listing walks.2224 const jobScan = 10002325 24-var jobStates = []string{"open", "done", "all"}26+// jobStates lists the state filters a listing accepts: one of the four27+// settled states, or all.28+var jobStates = []string{"open", "done", "failed", "cancelled", "all"}2529 2630 // jobRequestKinds lists the kinds classified as long-task requests.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- if event.IsJobRequest(kind) {31- kinds = append(kinds, kind)32- }33- }34- return kinds35-}()31+var jobRequestKinds = []int{event.KIND_JOB_REQUEST}32+33+// jobAnswerKinds lists every kind that answers or ends a request.34+var jobAnswerKinds = []int{event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS, event.KIND_JOB_RESULT, event.KIND_JOB_CANCEL, event.KIND_JOB_ERROR}3635 3736 type jobBrowseRequest struct {3837 Limit int `json:"limit"`
@@ -43,53 +42,41 @@ type jobBrowseRequest struct {
4342 Event string `json:"event"`4443 }4544 46-// jobInput is one i tag of a request.47-type jobInput struct {48- Data string `json:"data"`49- Type string `json:"type"`50- Relay string `json:"relay,omitempty"`51- Marker string `json:"marker,omitempty"`52-}53-54-// jobParam is one param tag of a request.55-type jobParam struct {56- Key string `json:"key"`57- Value string `json:"value"`58-}59-6045 // jobItem is one request as a browser sees it, settled against its answers.6146 type jobItem struct {62- ID string `json:"id"`63- Kind int `json:"kind"`64- Requester string `json:"requester"`65- Providers []string `json:"providers"`66- Inputs []jobInput `json:"inputs"`67- Output string `json:"output,omitempty"`68- Params []jobParam `json:"params"`69- Bid string `json:"bid,omitempty"`70- Relays []string `json:"relays"`71- Encrypted bool `json:"encrypted,omitempty"`72- Content string `json:"content"`73- CreatedAt int64 `json:"created_at"`74- Expires int64 `json:"expires,omitempty"`75- State string `json:"state"`76- Status string `json:"status,omitempty"`77- Feedback *jobAnswer `json:"feedback,omitempty"`78- Result *jobAnswer `json:"result,omitempty"`79- Event event.Event `json:"event"`47+ ID string `json:"id"`48+ Room string `json:"room"`49+ Requester string `json:"requester"`50+ Assignees []string `json:"assignees"`51+ Subject string `json:"subject,omitempty"`52+ Content string `json:"content"`53+ CreatedAt int64 `json:"created_at"`54+ Expires int64 `json:"expires,omitempty"`55+ Root string `json:"root,omitempty"`56+ State string `json:"state"`57+ Status string `json:"status"`58+ Progress *jobAnswer `json:"progress,omitempty"`59+ Result *jobAnswer `json:"result,omitempty"`60+ Error *jobAnswer `json:"error,omitempty"`61+ CancelledAt int64 `json:"cancelled_at,omitempty"`62+ Event event.Event `json:"event"`8063 }8164 82-// jobAnswer is one feedback or result event from a service provider.65+// jobAnswer is one accepted, progress, result, cancel or error event.8366 type jobAnswer struct {84- ID string `json:"id"`85- Kind int `json:"kind"`86- Provider string `json:"provider"`87- Status string `json:"status,omitempty"`88- Info string `json:"info,omitempty"`89- Amount string `json:"amount,omitempty"`90- Invoice string `json:"invoice,omitempty"`91- Content string `json:"content"`92- CreatedAt int64 `json:"created_at"`67+ ID string `json:"id"`68+ Kind int `json:"kind"`69+ Provider string `json:"provider"`70+ Content string `json:"content"`71+ CreatedAt int64 `json:"created_at"`72+ Artifacts []jobArtifact `json:"artifacts,omitempty"`73+}74+75+// jobArtifact is one artifact a result references: an event (e), an76+// addressable event (a) or a URL (r).77+type jobArtifact struct {78+ Type string `json:"type"`79+ Value string `json:"value"`9380 }9481 9582 func jobBrowseMethod(method string) bool {
@@ -121,15 +108,14 @@ func (t *Tenant) executeJobs(ctx context.Context, actor, method string, params [
121108 return nil, errors.New("unsupported: browse operation")122109 }123110 124-// browseJobs lists the requests the caller may see, newest first. A request125-// is done once a result exists or its newest feedback reports success or an126-// error; every other request is open. Expired requests are not served.111+// browseJobs lists the requests the caller may see, newest first, each112+// settled against its answers. Expired requests are not served.127113 func (t *Tenant) browseJobs(ctx context.Context, actor string, q jobBrowseRequest) (any, error) {128114 if q.State == "" {129115 q.State = "all"130116 }131117 if !containsString(jobStates, q.State) {132- return nil, errors.New("invalid: job state must be open, done or all")118+ return nil, errors.New("invalid: job state must be open, done, failed, cancelled or all")133119 }134120 if q.Mine && actor == "" {135121 return nil, errors.New("auth-required: sign in to list your own requests")
@@ -183,8 +169,7 @@ func (t *Tenant) browseJobs(ctx context.Context, actor string, q jobBrowseReques
183169 return map[string]any{"items": items, "next_cursor": next}, nil184170 }185171 186-// browseJob returns one request with its feedback, oldest first, and its187-// results, newest first.172+// browseJob returns one request with its answers, oldest first.188173 func (t *Tenant) browseJob(ctx context.Context, actor, id string) (any, error) {189174 if len(id) != 64 {190175 return nil, errors.New("invalid: job id")
@@ -203,78 +188,70 @@ func (t *Tenant) browseJob(ctx context.Context, actor, id string) (any, error) {
203188 return nil, err204189 }205190 jobSettle(&item, answers[item.ID])206- feedback, results := []jobAnswer{}, []jobAnswer{}207- for _, row := range answers[item.ID] {208- if row.Kind == event.KIND_JOB_FEEDBACK {209- feedback = append([]jobAnswer{jobAnswerFrom(row)}, feedback...)210- } else {211- results = append(results, jobAnswerFrom(row))212- }191+ timeline := make([]jobAnswer, 0, len(answers[item.ID]))192+ for i := len(answers[item.ID]) - 1; i >= 0; i-- {193+ timeline = append(timeline, jobAnswerFrom(answers[item.ID][i]))213194 }214- return map[string]any{"item": item, "feedback": feedback, "results": results}, nil195+ return map[string]any{"item": item, "answers": timeline}, nil215196 }216197 217-func jobItemFrom(e event.Event) jobItem {218- 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}198+// jobRoot names the thread a request attaches to: its e tag marked root.199+func jobRoot(e event.Event) string {219200 for _, tag := range e.Tags {220- switch {221- case len(tag) >= 3 && tag[0] == "i":222- input := jobInput{Data: tag[1], Type: tag[2]}223- if len(tag) > 3 {224- input.Relay = tag[3]225- }226- if len(tag) > 4 {227- input.Marker = tag[4]228- }229- item.Inputs = append(item.Inputs, input)230- case len(tag) >= 3 && tag[0] == "param":231- item.Params = append(item.Params, jobParam{Key: tag[1], Value: tag[2]})232- case len(tag) >= 2 && tag[0] == "relays":233- item.Relays = append(item.Relays, tag[1:]...)234- case len(tag) >= 1 && tag[0] == "encrypted":235- item.Encrypted = true201+ if len(tag) > 3 && tag[0] == "e" && tag[3] == "root" && len(tag[1]) == 64 {202+ return tag[1]236203 }237204 }238- return item205+ return ""206+}207+208+func jobItemFrom(e event.Event) jobItem {209+ assignees := event.TagValues(e, "p")210+ if assignees == nil {211+ assignees = []string{}212+ }213+ return jobItem{ID: e.ID, Room: event.Tag(e, "h"), Requester: e.PubKey, Assignees: assignees, Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), Root: jobRoot(e), State: "open", Status: "queued", Event: e}239214 }240215 241216 func jobAnswerFrom(row event.Event) jobAnswer {242217 answer := jobAnswer{ID: row.ID, Kind: row.Kind, Provider: row.PubKey, Content: row.Content, CreatedAt: row.CreatedAt}218+ if row.Kind != event.KIND_JOB_RESULT {219+ return answer220+ }221+ request := false243222 for _, tag := range row.Tags {244- switch {245- case len(tag) >= 2 && tag[0] == "status" && answer.Status == "":246- answer.Status = tag[1]247- if len(tag) > 2 {248- answer.Info = tag[2]249- }250- case len(tag) >= 2 && tag[0] == "amount" && answer.Amount == "":251- answer.Amount = tag[1]252- if len(tag) > 2 {253- answer.Invoice = tag[2]223+ if len(tag) < 2 || tag[1] == "" {224+ continue225+ }226+ switch tag[0] {227+ case "e":228+ // The first e tag names the request; later ones are artifacts.229+ if !request {230+ request = true231+ continue254232 }233+ answer.Artifacts = append(answer.Artifacts, jobArtifact{Type: "e", Value: tag[1]})234+ case "a", "r":235+ answer.Artifacts = append(answer.Artifacts, jobArtifact{Type: tag[0], Value: tag[1]})255236 }256237 }257238 return answer258239 }259240 260-// jobAnswers finds the feedback and results naming the given requests in261-// one query, newest first, keeping only what the session may see.241+// jobAnswers finds the answers naming the given requests in one query,242+// newest first, keeping only what the session may see.262243 func (t *Tenant) jobAnswers(ctx context.Context, items []jobItem, session relay.Session, now int64) (map[string][]event.Event, error) {263244 answers := make(map[string][]event.Event, len(items))264245 if len(items) == 0 {265246 return answers, nil266247 }267248 ids := make([]string, 0, len(items))268- kinds := []int{event.KIND_JOB_FEEDBACK}269- wanted := make(map[string]int, len(items))249+ wanted := make(map[string]bool, len(items))270250 for _, item := range items {271251 ids = append(ids, item.ID)272- wanted[item.ID] = event.JobResultKind(item.Kind)273- if !containsInt(kinds, wanted[item.ID]) {274- kinds = append(kinds, wanted[item.ID])275- }252+ wanted[item.ID] = true276253 }277- 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})254+ rows, err := t.store.Query(ctx, event.Filter{Kinds: jobAnswerKinds, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: now, Access: storage.Access{PubKeys: session.PubKeys}, Limit: jobScan})278255 if err != nil {279256 return nil, err280257 }
@@ -283,34 +260,58 @@ func (t *Tenant) jobAnswers(ctx context.Context, items []jobItem, session relay.
283260 if !t.gate.CanSee(ctx, row, session, nil) {284261 continue285262 }286- for _, id := range event.TagValues(row, "e") {287- resultKind, ok := wanted[id]288- if ok && (row.Kind == event.KIND_JOB_FEEDBACK || row.Kind == resultKind) {289- answers[id] = append(answers[id], row)290- }263+ if id := gates.JobRequestID(row); wanted[id] {264+ answers[id] = append(answers[id], row)291265 }292266 }293267 return answers, nil294268 }295269 296-// jobSettle fills the newest feedback, the newest result and the state of297-// one request from its answers, which arrive newest first.270+// jobSettle derives one request's state from its answers, which arrive271+// newest first. The newest terminal event wins: a result makes the task272+// done, a cancel cancelled and an error failed. Otherwise the newest273+// accepted or progress answer sets the status, and a request with no274+// answers is queued.298275 func jobSettle(item *jobItem, rows []event.Event) {276+ item.State, item.Status = "open", "queued"277+ item.Progress, item.Result, item.Error, item.CancelledAt = nil, nil, nil, 0278+ settled := false299279 for _, row := range rows {300280 answer := jobAnswerFrom(row)301- if row.Kind == event.KIND_JOB_FEEDBACK {302- if item.Feedback == nil {303- item.Feedback = &answer304- item.Status = answer.Status281+ switch row.Kind {282+ case event.KIND_JOB_RESULT:283+ if item.Result == nil {284+ item.Result = &answer285+ }286+ if !settled {287+ item.State, item.Status, settled = "done", "done", true288+ }289+ case event.KIND_JOB_ERROR:290+ if item.Error == nil {291+ item.Error = &answer292+ }293+ if !settled {294+ item.State, item.Status, settled = "failed", "failed", true295+ }296+ case event.KIND_JOB_CANCEL:297+ if item.CancelledAt == 0 {298+ item.CancelledAt = row.CreatedAt299+ }300+ if !settled {301+ item.State, item.Status, settled = "cancelled", "cancelled", true302+ }303+ case event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS:304+ if item.Progress == nil {305+ item.Progress = &answer306+ if !settled {307+ item.Status = "accepted"308+ if row.Kind == event.KIND_JOB_PROGRESS {309+ item.Status = "running"310+ }311+ }305312 }306- } else if item.Result == nil {307- item.Result = &answer308313 }309314 }310- item.State = "open"311- if item.Result != nil || item.Status == "success" || item.Status == "error" {312- item.State = "done"313- }314315 }315316 316317 func jobCursor(item jobItem) string {
M internal/daemon/jobs_test.go +196 -159
@@ -14,8 +14,11 @@ import (
1414 "github.com/FelineStateMachine/tinyrelay/internal/mcp"1515 )1616 17+// jobTestRoom is the test tenant's own room, open to every member.18+const jobTestRoom = "main"19+1720 // jobTenant returns a tenant with a member who requests jobs and an agent18-// granted to serve them.21+// granted to serve them in the tenant's room.1922 func jobTenant(t *testing.T) (*App, *Tenant, string, string) {2023 t.Helper()2124 app, tenant := testTenant(t)
@@ -27,14 +30,29 @@ func jobTenant(t *testing.T) (*App, *Tenant, string, string) {
2730 t.Fatal(err)2831 }2932 now := time.Now().Unix()30- if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"jobs", "serve"})); err != nil {33+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"room", jobTestRoom}, []string{"jobs", "serve"})); err != nil {3134 t.Fatalf("grant rejected: %v", err)3235 }3336 return app, tenant, member, agent3437 }3538 39+// jobRequestTags builds the tags of a request in the test room asking the40+// given keys.41+func jobRequestTags(asked []string, extra ...[]string) [][]string {42+ tags := [][]string{{"h", jobTestRoom}}43+ for _, key := range asked {44+ tags = append(tags, []string{"p", key})45+ }46+ return append(tags, extra...)47+}48+49+// jobAnswerTags builds the tags of an answer to a request in the test room.50+func jobAnswerTags(request event.Event, extra ...[]string) [][]string {51+ return append([][]string{{"h", jobTestRoom}, {"e", request.ID}, {"p", request.PubKey}}, extra...)52+}53+3654 func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {37- app, _, member, _ := jobTenant(t)55+ app, _, member, agent := jobTenant(t)3856 now := time.Now().Unix()3957 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {4058 t.Helper()
@@ -55,207 +73,198 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
5573 }5674 return content[0].(map[string]any)["text"].(string)5775 }76+ tagsOf := func(result map[string]any) (map[string]any, string) {77+ unsigned, _ := structured(result)["unsigned"].(map[string]any)78+ tags, _ := json.Marshal(unsigned["tags"])79+ return unsigned, string(tags)80+ }5881 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")5982 names := map[string]bool{}6083 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {6184 names[tool.(map[string]any)["name"].(string)] = true6285 }63- for _, want := range []string{"list_jobs", "read_job", "request_job", "job_feedback", "job_result"} {86+ for _, want := range []string{"list_jobs", "read_job", "request_job", "accept_job", "job_progress", "job_result", "job_error", "cancel_job"} {6487 if !names[want] {6588 t.Fatalf("missing tool %s", want)6689 }6790 }6891 69- // request_job: the template carries the NIP-90 tags in order, refuses70- // bad inputs and kinds, and the signed request publishes.71- source := strings.Repeat("d", 64)92+ // request_job: the template carries the room, the asked keys, the93+ // subject, the thread root and the expiration; it refuses a request94+ // without a room or an asked key, and the signed request publishes.95+ root := strings.Repeat("d", 64)7296 expires := strconv.FormatInt(now+3600, 10)73- result, isError := call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "text"}, map[string]any{"data": source, "type": "event", "relay": "wss://relay.example", "marker": "context"}}, "output": "text/plain", "params": map[string]any{"model": "small", "lang": "es"}, "bid": 1000, "relays": []any{"wss://a.example", " "}, "expiration": now + 3600}, testMemberSecret)74- unsigned, _ := structured(result)["unsigned"].(map[string]any)75- tags, _ := json.Marshal(unsigned["tags"])76- if isError || unsigned["kind"] != float64(5001) || unsigned["content"] != "" || string(tags) != `[["i","hello","text"],["i","`+source+`","event","wss://relay.example","context"],["output","text/plain"],["param","lang","es"],["param","model","small"],["bid","1000"],["relays","wss://a.example"],["expiration","`+expires+`"]]` {77- t.Fatalf("request_job template: %v %s", isError, text(result))97+ result, isError := call("request_job", map[string]any{"room": jobTestRoom, "assignees": []any{agent}, "subject": "Summarize the notes", "content": "Summarize this week's notes.", "root": root, "expiration": now + 3600}, testMemberSecret)98+ unsigned, tags := tagsOf(result)99+ if isError || unsigned["kind"] != float64(event.KIND_JOB_REQUEST) || unsigned["content"] != "Summarize this week's notes." || tags != `[["h","main"],["p","`+agent+`"],["subject","Summarize the notes"],["e","`+root+`","","root"],["expiration","`+expires+`"]]` {100+ t.Fatalf("request_job template: %v %s %s", isError, text(result), tags)78101 }79- if result, isError = call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "voice"}}}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job") {80- t.Fatalf("request_job bad input: %s", text(result))102+ if result, isError = call("request_job", map[string]any{"assignees": []any{agent}, "content": "x"}, testMemberSecret); !isError || !strings.Contains(text(result), "room is required") {103+ t.Fatalf("request_job without a room: %s", text(result))81104 }82- if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError {83- t.Fatalf("request_job bad kind: %s", text(result))105+ if result, isError = call("request_job", map[string]any{"room": jobTestRoom, "content": "x"}, testMemberSecret); !isError || !strings.Contains(text(result), "at least one key") {106+ t.Fatalf("request_job without assignees: %s", text(result))84107 }85- if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5127 or 5129 to 5999") {108+ if result, isError = call("request_job", map[string]any{"room": jobTestRoom, "assignees": []any{agent}}, testMemberSecret); !isError || !strings.Contains(text(result), "content or subject") {109+ t.Fatalf("request_job without text: %s", text(result))110+ }111+ if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, event.KIND_JOB_REQUEST, [][]string{{"h", jobTestRoom}}, "hello")}, testMemberSecret); !isError || !strings.Contains(text(result), "at least one asked key") || !strings.Contains(text(result), "Expected a signed kind 43001 event") {86112 t.Fatalf("request_job malformed signed: %s", text(result))87113 }88- request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")114+ request := mcpSigned(t, testMemberSecret, event.KIND_JOB_REQUEST, jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"}), "Summarize this week's notes.")89115 result, isError = call("request_job", map[string]any{"event": request}, testMemberSecret)90116 requestID, _ := structured(result)["event_id"].(string)91117 if isError || structured(result)["accepted"] != true || requestID == "" {92118 t.Fatalf("request_job publish: %s", text(result))93119 }94120 95- // job_feedback: the serving agent's template and signed publish, and96- // the status vocabulary.97- result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "processing", "info": "halfway", "amount": 500, "invoice": "lnbc1"}, testAgentSecret)98- unsigned, _ = structured(result)["unsigned"].(map[string]any)99- tags, _ = json.Marshal(unsigned["tags"])100- if isError || unsigned["kind"] != float64(7000) || string(tags) != `[["status","processing","halfway"],["e","`+requestID+`"],["p","`+member+`"],["amount","500","lnbc1"]]` {101- t.Fatalf("job_feedback template: %v %s", isError, text(result))121+ // accept_job and job_progress: the template takes the request event or122+ // its fields, and the signed answer publishes only from an asked key.123+ result, isError = call("accept_job", map[string]any{"request": request}, testAgentSecret)124+ unsigned, tags = tagsOf(result)125+ if isError || unsigned["kind"] != float64(event.KIND_JOB_ACCEPTED) || unsigned["content"] != "" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {126+ t.Fatalf("accept_job template: %v %s %s", isError, text(result), tags)127+ }128+ if result, isError = call("accept_job", map[string]any{"request": request, "room": "other"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") {129+ t.Fatalf("accept_job mismatched room: %s", text(result))102130 }103- if result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "done"}, testAgentSecret); !isError || !strings.Contains(text(result), "status") {104- t.Fatalf("job_feedback bad status: %s", text(result))131+ if result, isError = call("job_progress", map[string]any{"e": requestID, "p": member, "room": jobTestRoom}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {132+ t.Fatalf("job_progress without content: %s", text(result))105133 }106- if result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"e", requestID}, {"p", member}}, "")}, testAgentSecret); !isError || !strings.Contains(text(result), "payment-required, processing, error, success or partial") {107- t.Fatalf("job_feedback malformed signed: %s", text(result))134+ result, isError = call("job_progress", map[string]any{"e": requestID, "p": member, "room": jobTestRoom, "content": "halfway"}, testAgentSecret)135+ unsigned, tags = tagsOf(result)136+ if isError || unsigned["kind"] != float64(event.KIND_JOB_PROGRESS) || unsigned["content"] != "halfway" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {137+ t.Fatalf("job_progress template: %v %s %s", isError, text(result), tags)108138 }109- result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"status", "processing", "halfway"}, {"e", requestID}, {"p", member}}, "")}, testAgentSecret)139+ if result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_PROGRESS, [][]string{{"e", requestID}, {"p", member}}, "halfway")}, testAgentSecret); !isError || !strings.Contains(text(result), "room in an h tag") {140+ t.Fatalf("job_progress malformed signed: %s", text(result))141+ }142+ requestEvent := event.Event{ID: requestID, PubKey: member}143+ result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_PROGRESS, jobAnswerTags(requestEvent), "halfway")}, testAgentSecret)110144 if isError || structured(result)["accepted"] != true {111- t.Fatalf("job_feedback publish: %s", text(result))145+ t.Fatalf("job_progress publish: %s", text(result))146+ }147+ if result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testOwnerSecret, event.KIND_JOB_PROGRESS, jobAnswerTags(requestEvent), "not asked")}, testOwnerSecret); !isError || !strings.Contains(text(result), "key the request asked") {148+ t.Fatalf("job_progress from a key not asked: %s", text(result))112149 }113150 114- // job_result: the template derives the kind, the request tag and the115- // inputs from the request; the relay checks the answer against the116- // request it holds.117- result, isError = call("job_result", map[string]any{"request": request, "content": "hola", "amount": 700}, testAgentSecret)118- unsigned, _ = structured(result)["unsigned"].(map[string]any)119- resultTags, _ := unsigned["tags"].([]any)120- if isError || unsigned["kind"] != float64(6001) || unsigned["content"] != "hola" || len(resultTags) != 5 {121- t.Fatalf("job_result template: %v %s", isError, text(result))122- }123- requestTag := resultTags[0].([]any)124- var embedded event.Event125- if requestTag[0] != "request" || json.Unmarshal([]byte(requestTag[1].(string)), &embedded) != nil || embedded.ID != requestID {126- t.Fatalf("job_result request tag %v", requestTag)127- }128- tags, _ = json.Marshal(resultTags[1:])129- if string(tags) != `[["e","`+requestID+`"],["i","hello","text"],["p","`+member+`"],["amount","700"]]` {130- t.Fatalf("job_result tags %s", tags)131- }132- if result, isError = call("job_result", map[string]any{"request": request, "kind": 6002, "content": "hola"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") {133- t.Fatalf("job_result mismatched kind: %s", text(result))134- }135- if result, isError = call("job_result", map[string]any{"kind": 6001, "e": requestID, "p": member}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {151+ // job_result: the template carries the artifacts and refuses bad ones;152+ // the relay checks the answer against the request it holds.153+ artifacts := []any{map[string]any{"type": "e", "value": root}, map[string]any{"type": "a", "value": "30818:" + member + ":notes"}, map[string]any{"type": "r", "value": "https://example.com/report"}}154+ result, isError = call("job_result", map[string]any{"request": request, "content": "the summary", "artifacts": artifacts}, testAgentSecret)155+ unsigned, tags = tagsOf(result)156+ if isError || unsigned["kind"] != float64(event.KIND_JOB_RESULT) || unsigned["content"] != "the summary" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"],["e","`+root+`"],["a","30818:`+member+`:notes"],["r","https://example.com/report"]]` {157+ t.Fatalf("job_result template: %v %s %s", isError, text(result), tags)158+ }159+ if result, isError = call("job_result", map[string]any{"request": request, "content": "x", "artifacts": []any{map[string]any{"type": "r", "value": "ftp://example.com"}}}, testAgentSecret); !isError || !strings.Contains(text(result), "each artifact") {160+ t.Fatalf("job_result bad artifact: %s", text(result))161+ }162+ if result, isError = call("job_result", map[string]any{"e": requestID, "p": member, "room": jobTestRoom}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {136163 t.Fatalf("job_result without content: %s", text(result))137164 }138- if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6002, [][]string{{"e", requestID}, {"p", member}}, "hola")}, testAgentSecret); !isError || !strings.Contains(text(result), "does not answer a kind 5001 request") {139- t.Fatalf("job_result wrong kind rejected by relay: %s", text(result))140- }141- signedTags := [][]string{}142- for _, tag := range resultTags {143- values := []string{}144- for _, value := range tag.([]any) {145- values = append(values, value.(string))146- }147- signedTags = append(signedTags, values)165+ if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_RESULT, [][]string{{"h", jobTestRoom}, {"e", strings.Repeat("e", 64)}, {"p", member}}, "x")}, testAgentSecret); !isError || !strings.Contains(text(result), "request this relay holds") {166+ t.Fatalf("job_result for an unknown request: %s", text(result))148167 }149- result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6001, signedTags, "hola")}, testAgentSecret)168+ result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_RESULT, jobAnswerTags(requestEvent, []string{"r", "https://example.com/report"}), "the summary")}, testAgentSecret)150169 resultID, _ := structured(result)["event_id"].(string)151170 if isError || structured(result)["accepted"] != true || resultID == "" {152171 t.Fatalf("job_result publish: %s", text(result))153172 }154173 174+ // job_error and cancel_job build their events; only the requester175+ // cancels.176+ result, isError = call("job_error", map[string]any{"request": request, "content": "no such input"}, testAgentSecret)177+ unsigned, tags = tagsOf(result)178+ if isError || unsigned["kind"] != float64(event.KIND_JOB_ERROR) || unsigned["content"] != "no such input" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {179+ t.Fatalf("job_error template: %v %s %s", isError, text(result), tags)180+ }181+ result, isError = call("cancel_job", map[string]any{"request": request, "p": agent, "content": "never mind"}, testMemberSecret)182+ unsigned, tags = tagsOf(result)183+ if isError || unsigned["kind"] != float64(event.KIND_JOB_CANCEL) || unsigned["content"] != "never mind" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+agent+`"]]` {184+ t.Fatalf("cancel_job template: %v %s %s", isError, text(result), tags)185+ }186+ if result, isError = call("cancel_job", map[string]any{"event": mcpSigned(t, testOwnerSecret, event.KIND_JOB_CANCEL, [][]string{{"h", jobTestRoom}, {"e", requestID}}, "")}, testOwnerSecret); !isError || !strings.Contains(text(result), "only the requester") {187+ t.Fatalf("cancel_job from another key: %s", text(result))188+ }189+155190 // The reads show the settled request.156191 result, isError = call("list_jobs", map[string]any{"state": "done", "mine": true}, testMemberSecret)157192 items, _ := structured(result)["items"].([]any)158- if isError || len(items) != 1 || items[0].(map[string]any)["id"] != requestID || items[0].(map[string]any)["status"] != "processing" || items[0].(map[string]any)["result"].(map[string]any)["id"] != resultID {193+ if isError || len(items) != 1 {159194 t.Fatalf("list_jobs: %s", text(result))160195 }196+ item := items[0].(map[string]any)197+ answer, _ := item["result"].(map[string]any)198+ if item["id"] != requestID || item["status"] != "done" || item["subject"] != "Summarize the notes" || answer["id"] != resultID || answer["artifacts"].([]any)[0].(map[string]any)["value"] != "https://example.com/report" {199+ t.Fatalf("list_jobs item: %v", item)200+ }161201 result, isError = call("read_job", map[string]any{"id": requestID}, testMemberSecret)162- if isError || len(structured(result)["feedback"].([]any)) != 1 || len(structured(result)["results"].([]any)) != 1 {202+ if isError || len(structured(result)["answers"].([]any)) != 2 {163203 t.Fatalf("read_job: %s", text(result))164204 }165205 r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil)166206 w := httptest.NewRecorder()167207 app.ServeHTTP(w, r)168- if !strings.Contains(w.Body.String(), "request_job, job_feedback and job_result") || !strings.Contains(w.Body.String(), "list_jobs and read_job") {208+ if !strings.Contains(w.Body.String(), "request_job, accept_job, job_progress, job_result, job_error and cancel_job") || !strings.Contains(w.Body.String(), "list_jobs and read_job") || strings.Contains(w.Body.String(), "NIP-90") {169209 t.Fatalf("llms.txt: %s", w.Body.String())170210 }171211 }172212 173-func TestMCPJobBuildersKeepRoomScope(t *testing.T) {174- room := "private-work"175- request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{176- "kind": float64(5001), "room": room,177- "inputs": []any{map[string]any{"data": "hello", "type": "text"}},178- }})179- if err != nil {180- t.Fatalf("request builder: %v", err)181- }182- if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {183- t.Fatalf("request room = %q, want %q", got, room)184- }185-186- feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{187- "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64),188- "room": room, "status": "processing",189- }})190- if err != nil {191- t.Fatalf("feedback builder: %v", err)192- }193- if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room {194- t.Fatalf("feedback room = %q, want %q", got, room)195- }196-197- requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags}198- result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{199- "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""},200- "content": "done",201- }})202- if err != nil {203- t.Fatalf("result builder: %v", err)204- }205- if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room {206- t.Fatalf("result room = %q, want %q", got, room)207- }208-}209-210-func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) {213+func TestJobAnswersWakeTheRequester(t *testing.T) {211214 _, tenant, member, agent := jobTenant(t)212215 ctx := context.Background()213216 now := time.Now().Unix()214- request := wikiPublish(t, tenant, testMemberSecret, 5001, now-100, "", []string{"i", "hello", "text"})215- result := event.Event{Kind: 6001, PubKey: agent, CreatedAt: now, Tags: [][]string{{"e", request.ID}, {"p", member}}, Content: "hola"}217+ request := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-100, "Summarize this week's notes.", jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"})...)218+ untitled := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-90, "Build the preview.", jobRequestTags([]string{agent})...)219+ result := event.Event{Kind: event.KIND_JOB_RESULT, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(request), Content: "the summary"}216220 notices := tenant.pushNotices(ctx, result)217- 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 {221+ if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != member || notices[0].body != "task Summarize the notes done" || !strings.HasSuffix(notices[0].url, "/e/"+result.ID) || len(notices[0].actions) != 0 {218222 t.Fatalf("result notices %+v", notices)219223 }220- for status, want := range map[string]int{"error": 1, "payment-required": 1, "processing": 0, "success": 0, "partial": 0} {221- feedback := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", status}, {"e", request.ID}, {"p", member}}}222- notices := tenant.pushNotices(ctx, feedback)223- if len(notices) != want {224- t.Fatalf("%s feedback notices %+v", status, notices)225- }226- if want == 1 && (notices[0].category != pushMentions || notices[0].body != "job 5001 "+status) {227- t.Fatalf("%s feedback notice %+v", status, notices[0])224+ failed := event.Event{Kind: event.KIND_JOB_ERROR, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(untitled), Content: "no such input"}225+ if notices := tenant.pushNotices(ctx, failed); len(notices) != 1 || notices[0].category != pushMentions || notices[0].body != "task "+untitled.ID[:8]+" failed" {226+ t.Fatalf("error notices %+v", notices)227+ }228+ for _, kind := range []int{event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS} {229+ quiet := event.Event{Kind: kind, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(request), Content: "halfway"}230+ if notices := tenant.pushNotices(ctx, quiet); len(notices) != 0 {231+ t.Fatalf("kind %d notices %+v", kind, notices)228232 }229233 }230- // Feedback for a request the relay does not hold names its own kind.231- elsewhere := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", strings.Repeat("e", 64)}, {"p", member}}}232- if notices := tenant.pushNotices(ctx, elsewhere); len(notices) != 1 || notices[0].body != "job 7000 error" {233- t.Fatalf("elsewhere notices %+v", notices)234+ // The requester's own cancel wakes nobody, and neither does a result235+ // the requester publishes.236+ cancel := event.Event{Kind: event.KIND_JOB_CANCEL, PubKey: member, CreatedAt: now, Tags: [][]string{{"h", jobTestRoom}, {"e", request.ID}, {"p", agent}}}237+ if notices := tenant.pushNotices(ctx, cancel); len(notices) != 0 {238+ t.Fatalf("cancel notices %+v", notices)234239 }235- // The requester's own feedback wakes nobody.236- own := event.Event{Kind: 7000, PubKey: member, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", request.ID}, {"p", member}}}240+ own := event.Event{Kind: event.KIND_JOB_RESULT, PubKey: member, CreatedAt: now, Tags: jobAnswerTags(request), Content: "x"}237241 if notices := tenant.pushNotices(ctx, own); len(notices) != 0 {238242 t.Fatalf("own notices %+v", notices)239243 }240244 }241245 242-func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {246+func TestBrowseJobsListsRequestsWithStateAndAnswers(t *testing.T) {243247 _, tenant, member, agent := jobTenant(t)244248 ctx := context.Background()245- owner := tenant.Policy().Owner246249 now := time.Now().Unix()250+ root := wikiPublish(t, tenant, testMemberSecret, event.KIND_CHAT, now-500, "Could you summarize the notes?", []string{"h", jobTestRoom})247251 248- 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})249- wikiPublish(t, tenant, testAgentSecret, 7000, now-350, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"})250- working := wikiPublish(t, tenant, testAgentSecret, 7000, now-300, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "processing", "halfway"})251- done := wikiPublish(t, tenant, testMemberSecret, 5002, now-390, "", []string{"i", "summarize", "text"})252- wikiPublish(t, tenant, testAgentSecret, 7000, now-380, "", []string{"e", done.ID}, []string{"p", member}, []string{"status", "processing"})253- result := wikiPublish(t, tenant, testAgentSecret, 6002, now-200, "the summary", []string{"e", done.ID}, []string{"p", member}, []string{"amount", "700"}, []string{"request", "{}"})254- failed := wikiPublish(t, tenant, testOwnerSecret, 5001, now-300, "", []string{"i", "broken", "text"})255- wikiPublish(t, tenant, testAgentSecret, 7000, now-250, "", []string{"e", failed.ID}, []string{"p", owner}, []string{"status", "error", "no such input"})252+ open := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-400, "Summarize this week's notes.", jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"}, []string{"e", root.ID, "", "root"})...)253+ wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ACCEPTED, now-350, "", jobAnswerTags(open)...)254+ working := wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-300, "halfway", jobAnswerTags(open)...)255+ done := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-390, "Build the preview.", jobRequestTags([]string{agent})...)256+ wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-380, "building", jobAnswerTags(done)...)257+ result := wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_RESULT, now-200, "the preview", jobAnswerTags(done, []string{"e", root.ID}, []string{"a", "30818:" + member + ":notes"}, []string{"r", "https://example.com/preview"})...)258+ failed := wikiPublish(t, tenant, testOwnerSecret, event.KIND_JOB_REQUEST, now-300, "Transcribe the call.", jobRequestTags([]string{agent})...)259+ wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ERROR, now-250, "no such input", jobAnswerTags(failed)...)260+ cancelled := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-280, "Never mind this one.", jobRequestTags([]string{agent})...)261+ wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ACCEPTED, now-270, "", jobAnswerTags(cancelled)...)262+ wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_CANCEL, now-260, "", []string{"h", jobTestRoom}, []string{"e", cancelled.ID}, []string{"p", agent})263+ // A late progress line does not reopen a cancelled task.264+ wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-255, "still going", jobAnswerTags(cancelled)...)256265 // An expired request is saved directly, as it would have been before it257266 // lapsed, and is no longer served.258- expired := event.Event{Kind: 5001, CreatedAt: now - 7200, Content: "", Tags: [][]string{{"expiration", strconv.FormatInt(now-3600, 10)}}}267+ expired := event.Event{Kind: event.KIND_JOB_REQUEST, CreatedAt: now - 7200, Content: "old", Tags: jobRequestTags([]string{agent}, []string{"expiration", strconv.FormatInt(now-3600, 10)})}259268 if err := event.Sign(&expired, testMemberSecret); err != nil {260269 t.Fatal(err)261270 }
@@ -269,37 +278,37 @@ func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
269278 t.Fatal(err)270279 }271280 items := wikiItems(listed, "items")272- if len(items) != 3 || items[0]["id"] != failed.ID || items[1]["id"] != done.ID || items[2]["id"] != open.ID || listed["next_cursor"] != "" {281+ if len(items) != 4 || items[0]["id"] != cancelled.ID || items[1]["id"] != failed.ID || items[2]["id"] != done.ID || items[3]["id"] != open.ID || listed["next_cursor"] != "" {273282 t.Fatalf("listed %v", items)274283 }275284 item := approvalByID(items, open.ID)276- feedback, _ := item["feedback"].(map[string]any)277- 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 {285+ progress, _ := item["progress"].(map[string]any)286+ if item["state"] != "open" || item["status"] != "running" || item["room"] != jobTestRoom || item["requester"] != member || item["subject"] != "Summarize the notes" || item["content"] != "Summarize this week's notes." || item["root"] != root.ID || item["result"] != nil || item["error"] != nil || progress == nil || progress["id"] != working.ID || progress["content"] != "halfway" || progress["provider"] != agent {278287 t.Fatalf("open item %v", item)279288 }280- 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" {281- t.Fatalf("inputs %v", item["inputs"])282- }283- if params := item["params"].([]any); len(params) != 1 || params[0].(map[string]any)["key"] != "lang" || params[0].(map[string]any)["value"] != "es" {284- t.Fatalf("params %v", item["params"])285- }286- if relays := item["relays"].([]any); len(relays) != 2 || relays[1] != "wss://b.example" {287- t.Fatalf("relays %v", item["relays"])288- }289- if providers := item["providers"].([]any); len(providers) != 1 || providers[0] != agent {290- t.Fatalf("providers %v", item["providers"])289+ if assignees := item["assignees"].([]any); len(assignees) != 1 || assignees[0] != agent {290+ t.Fatalf("assignees %v", item["assignees"])291291 }292292 item = approvalByID(items, done.ID)293293 answer, _ := item["result"].(map[string]any)294- if item["state"] != "done" || item["status"] != "processing" || answer == nil || answer["id"] != result.ID || answer["kind"] != 6002.0 || answer["amount"] != "700" || answer["content"] != "the summary" {294+ artifacts, _ := answer["artifacts"].([]any)295+ if item["state"] != "done" || item["status"] != "done" || item["subject"] != nil || answer["id"] != result.ID || answer["kind"] != float64(event.KIND_JOB_RESULT) || answer["content"] != "the preview" || len(artifacts) != 3 {295296 t.Fatalf("done item %v", item)296297 }298+ if first, last := artifacts[0].(map[string]any), artifacts[2].(map[string]any); first["type"] != "e" || first["value"] != root.ID || last["type"] != "r" || last["value"] != "https://example.com/preview" {299+ t.Fatalf("artifacts %v", artifacts)300+ }297301 item = approvalByID(items, failed.ID)298- if item["state"] != "done" || item["status"] != "error" || item["result"] != nil {302+ failure, _ := item["error"].(map[string]any)303+ if item["state"] != "failed" || item["status"] != "failed" || item["result"] != nil || failure == nil || failure["content"] != "no such input" {299304 t.Fatalf("failed item %v", item)300305 }306+ item = approvalByID(items, cancelled.ID)307+ if item["state"] != "cancelled" || item["status"] != "cancelled" || item["cancelled_at"] != float64(now-260) || item["progress"].(map[string]any)["content"] != "still going" {308+ t.Fatalf("cancelled item %v", item)309+ }301310 302- for state, want := range map[string][]string{"open": {open.ID}, "done": {failed.ID, done.ID}} {311+ for state, want := range map[string][]string{"open": {open.ID}, "done": {done.ID}, "failed": {failed.ID}, "cancelled": {cancelled.ID}} {303312 filtered, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": state})304313 if err != nil {305314 t.Fatal(err)
@@ -316,7 +325,7 @@ func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
316325 if err != nil {317326 t.Fatal(err)318327 }319- if items := wikiItems(mine, "items"); len(items) != 2 || approvalByID(items, failed.ID) != nil {328+ if items := wikiItems(mine, "items"); len(items) != 3 || approvalByID(items, failed.ID) != nil {320329 t.Fatalf("mine %v", items)321330 }322331 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
@@ -326,14 +335,14 @@ func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
326335 t.Fatalf("guest mine %v", err)327336 }328337 // Guests see the requests on an open relay; pages follow the cursor.329- paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2})338+ paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 3})330339 if err != nil {331340 t.Fatal(err)332341 }333- if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {342+ if items := wikiItems(paged, "items"); len(items) != 3 || paged["next_cursor"] == "" {334343 t.Fatalf("paged %v", paged)335344 }336- rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})345+ rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 3, "cursor": paged["next_cursor"]})337346 if err != nil {338347 t.Fatal(err)339348 }
@@ -345,16 +354,16 @@ func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
345354 if err != nil {346355 t.Fatal(err)347356 }348- timeline := wikiItems(detail, "feedback")349- 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 {357+ timeline := wikiItems(detail, "answers")358+ if item := detail["item"].(map[string]any); item["state"] != "open" || len(timeline) != 2 || timeline[0]["kind"] != float64(event.KIND_JOB_ACCEPTED) || timeline[1]["id"] != working.ID {350359 t.Fatalf("open detail %v", detail)351360 }352- detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": done.ID})361+ detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": cancelled.ID})353362 if err != nil {354363 t.Fatal(err)355364 }356- if results := wikiItems(detail, "results"); len(results) != 1 || results[0]["id"] != result.ID || len(wikiItems(detail, "feedback")) != 1 {357- t.Fatalf("done detail %v", detail)365+ if timeline := wikiItems(detail, "answers"); len(timeline) != 3 || timeline[1]["kind"] != float64(event.KIND_JOB_CANCEL) || timeline[1]["provider"] != member {366+ t.Fatalf("cancelled detail %v", detail)358367 }359368 for _, id := range []string{note.ID, expired.ID, strings.Repeat("f", 64)} {360369 if _, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": id}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
@@ -371,3 +380,31 @@ func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
371380 t.Fatalf("browse with jobs off %v", err)372381 }373382 }383+384+func TestMCPJobBuildersKeepRoomScope(t *testing.T) {385+ room := "private-work"386+ agent := strings.Repeat("b", 64)387+ request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"room": room, "assignees": []any{agent}, "content": "hello"}})388+ if err != nil {389+ t.Fatalf("request builder: %v", err)390+ }391+ if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {392+ t.Fatalf("request room = %q, want %q", got, room)393+ }394+ requestEvent := map[string]any{"id": strings.Repeat("c", 64), "pubkey": strings.Repeat("d", 64), "kind": float64(event.KIND_JOB_REQUEST), "created_at": float64(1), "tags": request.Tags, "content": "hello"}395+ for name, build := range map[string]func(mcp.Call) (mcpUnsigned, error){"accept": mcpBuildJobAnswer(event.KIND_JOB_ACCEPTED), "result": mcpBuildJobAnswer(event.KIND_JOB_RESULT), "cancel": mcpBuildJobCancel} {396+ answer, err := build(mcp.Call{Arguments: map[string]any{"request": requestEvent, "content": "done"}})397+ if err != nil {398+ t.Fatalf("%s builder: %v", name, err)399+ }400+ if got := event.Tag(event.Event{Tags: answer.Tags}, "h"); got != room {401+ t.Fatalf("%s room = %q, want %q", name, got, room)402+ }403+ if got := event.Tag(event.Event{Tags: answer.Tags}, "e"); got != strings.Repeat("c", 64) {404+ t.Fatalf("%s request = %q", name, got)405+ }406+ }407+ if _, err := mcpBuildJobAnswer(event.KIND_JOB_PROGRESS)(mcp.Call{Arguments: map[string]any{"e": strings.Repeat("c", 64), "p": strings.Repeat("d", 64), "content": "x"}}); err == nil {408+ t.Fatal("progress builder accepted an answer without a room")409+ }410+}
M internal/daemon/mcp_http.go +3 -3
@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
2020 t.mcp = &mcp.Server{2121 Tools: tools,2222 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},23- Instructions: "This relay hosts Social notes and articles, Chat, files, Git repositories, sites and a wiki. Read tools return only data your key may see; management tools follow the relay's roles. Signed write tools accept an event, or return an unsigned event for you to sign and resubmit as event. An unsigned result is not a publication. upload_attachment stores room media; pass its descriptor in attachments to post_message, start_thread or reply_in_thread. request_decision asks a person for a kind 7 approval or denial, or a kind 1111 reply. request_grant lets an active agent request scope changes with a reason of 1 to 500 Unicode characters; sign and submit the returned kind 1111 request. Only the current operator's signed kind 30392 replacement grants access; a + reaction does not. request_job, job_feedback and job_result publish NIP-90 tasks, progress and results; list_jobs and read_job follow them. add_callback registers an HTTPS endpoint for signed event notifications. Read /llms.txt and the linked guides for authentication, grants, file access, event formats and optional runner setup. Tool availability does not override tenant policy or your grant.",23+ Instructions: "This relay hosts Social notes and articles, Chat, files, Git repositories, sites and a wiki. Read tools return only data your key may see; management tools follow the relay's roles. Signed write tools accept an event, or return an unsigned event for you to sign and resubmit as event. An unsigned result is not a publication. upload_attachment stores room media; pass its descriptor in attachments to post_message, start_thread or reply_in_thread. request_decision asks a person for a kind 7 approval or denial, or a kind 1111 reply. request_grant lets an active agent request scope changes with a reason of 1 to 500 Unicode characters; sign and submit the returned kind 1111 request. Only the current operator's signed kind 30392 replacement grants access; a + reaction does not. request_job asks keys to take on a long task (kind 43001) in a room; accept_job, job_progress, job_result and job_error answer it (kinds 43002, 43003, 43004 and 43006), cancel_job withdraws it (kind 43005), and list_jobs and read_job follow it. add_callback registers an HTTPS endpoint for signed event notifications. Read /llms.txt and the linked guides for authentication, grants, file access, event formats and optional runner setup. Tool availability does not override tenant policy or your grant.",2424 Authenticate: t.mcpAuthenticate,2525 Challenge: mcpChallenge,2626 Origin: t.mcpOrigin,
@@ -95,7 +95,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9595 "- Methods: server/discover, tools/list and tools/call.",9696 "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_attachment, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",9797 "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",98- "- Signed write tools: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_grant, request_job, job_feedback and job_result. Builders return an unsigned event when called with plain fields: sign it with your Nostr key and call again with the signed event as event. This first call does not publish anything. publish_event accepts an already signed event.",98+ "- Signed write tools: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_grant, request_job, accept_job, job_progress, job_result, job_error and cancel_job. Builders return an unsigned event when called with plain fields: sign it with your Nostr key and call again with the signed event as event. This first call does not publish anything. publish_event accepts an already signed event.",9999 "",100100 "## Agents, approvals and tasks",101101 "",
@@ -103,7 +103,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
103103 "- For missing access, call request_grant with reason (1 to 500 Unicode characters) and changes containing kinds, rooms, repos, sites, wiki, jobs or rate. Kinds and rooms are added; repository and site entries replace matching identities; wiki, jobs and rate replace their fields. Unspecified fields remain unchanged. The tool derives the active grant and current operator, returns an unsigned kind 1111 request, and accepts it after you sign it. This narrow request does not require a kind 1111 grant.",104104 "- Grant requests are visible only to their author and operator. The operator receives a notification and reviews the exact changes at "+base+"/approvals. Approval publishes a kind 30392 replacement carrying grant-request and grant-base references. A kind 7 + is not authority to grant access; - denies the request. Stale, inactive, denied or expired requests cannot change a grant. Observe the signed replacement before relying on new permissions.",105105 "- request_decision asks a person to approve, decide or answer a question. Query kinds 7 and 1111 with #e set to the request ID: + approves, - declines, and a kind 1111 reply supplies an answer from the asked key. Ordinary decisions do not apply settings automatically. Wiki proposals and merge requests also appear in Approvals, with their own authorized reviewers.",106- "- NIP-90 tasks: request_job publishes kinds 5000-5999 except reserved kind 5128; job_feedback publishes kind 7000; job_result publishes the request kind plus 1000. Preserve the request's h room tag on feedback and results. Chat folds progress and results into task cards; list_jobs and read_job return the work visible to your key.",106+ "- Long tasks: request_job publishes a kind 43001 request in a room with p tags for the keys asked. Only a key the request asked answers it: accept_job (43002), job_progress (43003) and then job_result (43004) or job_error (43006); the requester withdraws it with cancel_job (43005). Every answer names the request in e, the requester in p and keeps the request's h room tag. Chat folds progress, results and errors into task cards; list_jobs and read_job return the work visible to your key.",107107 "- The optional tiny agent command runs a separate ACP version 1 or text-command process for authorized room mentions. It supports bounded queues, batching, permission prompts, cancellation and restart recovery. Interrupted work may run again. It is not started by the relay daemon; see the agents guide for setup, grants and timeouts.",108108 "- add_callback provides event delivery to HTTPS endpoints with signed requests and retries when a persistent connection is unsuitable. Callbacks do not expand your access.",109109 "",
M internal/daemon/mcp_tools.go +186 -188
@@ -8,7 +8,6 @@ import (
88 "net"99 "net/url"1010 "regexp"11- "sort"1211 "strconv"1312 "strings"1413 "time"
@@ -41,10 +40,9 @@ var (
4140 mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}4241 mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}4342 mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}44- mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "not": map[string]any{"enum": []int{event.KIND_SITE_SNAPSHOT}}, "description": "NIP-90 job request kind, 5000 to 5127 or 5129 to 5999."}45- mcpMsats = map[string]any{"type": "integer", "minimum": 0, "description": "Amount in millisats."}46- mcpJobStatus = map[string]any{"type": "string", "enum": event.JobFeedbackStatuses}47- mcpJobInputs = map[string]any{"type": "array", "items": mcp.Object(map[string]any{"data": mcpText, "type": map[string]any{"type": "string", "enum": event.JobInputTypes}, "relay": mcpText, "marker": mcpText}, "data", "type"), "description": "Job inputs, each becoming an i tag."}43+ mcpJobEvent = map[string]any{"type": "object", "description": "The kind 43001 job request event being answered; supplies e, p and room."}44+ mcpAssignees = map[string]any{"type": "array", "items": mcpPubKey, "minItems": 1, "description": "Keys asked to do the work, each becoming a p tag."}45+ mcpArtifacts = map[string]any{"type": "array", "items": mcp.Object(map[string]any{"type": map[string]any{"type": "string", "enum": []string{"e", "a", "r"}}, "value": map[string]any{"type": "string", "minLength": 1}}, "type", "value"), "description": "Artifacts the result references, each becoming a tag: e an event id, a an addressable event coordinate, r an http or https URL."}4846 mcpReads = map[string]any{"readOnlyHint": true, "openWorldHint": false}4947 mcpChanges = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false}5048 mcpControls = map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
@@ -59,24 +57,24 @@ var (
5957 )6058 6159 const (62- mcpIssueShape = `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"] and optional ["t","<label>"] tags, with the Markdown description in content.`63- mcpPullShape = `Expected a signed kind 1618 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"], ["c","<40-hex commit>"], ["clone","<http or https URL>"], optional ["merge-base","<40-hex commit>"] and optional ["t","<label>"] tags, with the description in content.`64- mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment. A review comment under a pull request or patch may add ["file","<path>"] and ["line","<n>","old" or "new"] to name one diff line.`65- mcpStatusShape = `Expected a signed event of kind 1630 (open), 1631 (resolved or merged), 1632 (closed) or 1633 (draft) with tags ["a","30617:<owner>:<repo>"], ["e","<root id>","","root"] and ["p","<root pubkey>"].`66- mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`67- mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`68- mcpReplyShape = `Expected a signed kind 12 event with tags ["h","<room id>"], ["e","<thread root id>"] and optional ["p","<root author>"], with the reply in content.`69- mcpReactShape = `Expected a signed kind 7 event with tags ["e","<event id>"], ["p","<event author>"] and, for a room message, ["h","<room id>"], with "+", "-" or one emoji in content.`70- mcpWikiShape = `Expected a signed kind 30818 event with tags ["d","<normalized page name>"], ["title","<title>"], optional ["summary","<summary>"] and, for a fork, ["a","30818:<author>:<page name>","","fork"] and ["e","<version id>","","fork"], with the Djot article in content.`71- mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.`72- mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`73- mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"] and optional ["e","<thread root id>","","root"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.`74- mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional ["h","<room id>"], ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`75- mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"] and ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`76- mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"], ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.`77- mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.`78- mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."79- mcpAnswerNote = " The asked key answers with a kind 7 reaction (+ approves, - declines) or a kind 1111 NIP-22 reply. Query kinds 7 and 1111 with #e set to the published request's event id to collect answers. A question's text answer is a reply, not an arbitrary reaction."60+ mcpIssueShape = `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"] and optional ["t","<label>"] tags, with the Markdown description in content.`61+ mcpPullShape = `Expected a signed kind 1618 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"], ["c","<40-hex commit>"], ["clone","<http or https URL>"], optional ["merge-base","<40-hex commit>"] and optional ["t","<label>"] tags, with the description in content.`62+ mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment. A review comment under a pull request or patch may add ["file","<path>"] and ["line","<n>","old" or "new"] to name one diff line.`63+ mcpStatusShape = `Expected a signed event of kind 1630 (open), 1631 (resolved or merged), 1632 (closed) or 1633 (draft) with tags ["a","30617:<owner>:<repo>"], ["e","<root id>","","root"] and ["p","<root pubkey>"].`64+ mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`65+ mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`66+ mcpReplyShape = `Expected a signed kind 12 event with tags ["h","<room id>"], ["e","<thread root id>"] and optional ["p","<root author>"], with the reply in content.`67+ mcpReactShape = `Expected a signed kind 7 event with tags ["e","<event id>"], ["p","<event author>"] and, for a room message, ["h","<room id>"], with "+", "-" or one emoji in content.`68+ mcpWikiShape = `Expected a signed kind 30818 event with tags ["d","<normalized page name>"], ["title","<title>"], optional ["summary","<summary>"] and, for a fork, ["a","30818:<author>:<page name>","","fork"] and ["e","<version id>","","fork"], with the Djot article in content.`69+ mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.`70+ mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`71+ mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"] and optional ["e","<thread root id>","","root"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.`72+ mcpJobRequestShape = `Expected a signed kind 43001 event with tags ["h","<room id>"], one or more ["p","<asked pubkey>"], optional ["subject","<title>"], ["e","<thread root id>","","root"] and ["expiration","<unix time>"], with the task text in content.`73+ mcpJobAnswerShape = `Expected a signed kind 43002 (accepted), 43003 (progress), 43004 (result) or 43006 (error) event with tags ["e","<request id>"], ["p","<requester pubkey>"] and ["h","<room id>"] matching the request, with the progress line, output or error message in content. A result may add ["e","<event id>"], ["a","<coordinate>"] and ["r","<http or https URL>"] tags for the artifacts it produced.`74+ mcpJobCancelShape = `Expected a signed kind 43005 event from the requester with tags ["e","<request id>"], ["h","<room id>"] and optional ["p","<asked pubkey>"].`75+ mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.`76+ mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."77+ mcpAnswerNote = " The asked key answers with a kind 7 reaction (+ approves, - declines) or a kind 1111 NIP-22 reply. Query kinds 7 and 1111 with #e set to the published request's event id to collect answers. A question's text answer is a reply, not an arbitrary reaction."8078 )8179 8280 // mcpUnsigned is the event body a client signs before calling a write tool
@@ -130,8 +128,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
130128 add("list_agents", "List granted agents with their name, key, owner, expiration, paused and revoked state, scope and last event. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {131129 return t.mcpManage(ctx, call, "listagents")132130 })133- add("list_jobs", "List long task requests (NIP-90 job requests) visible to your key, 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.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "state": map[string]any{"type": "string", "enum": jobStates}, "mine": map[string]any{"type": "boolean"}}), mcpReads, t.mcpBrowse("browsejobs"))134- add("read_job", "Read one long task request by event id with its feedback timeline and results.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsejob"))131+ add("list_jobs", "List long task requests (kind 43001) visible to your key, each with its room, requester, assignees, subject, state (open, done, failed or cancelled), status (queued, accepted, running, done, failed or cancelled), newest progress, result with its artifacts, error and cancel time. state narrows the list; mine lists only your own requests.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "state": map[string]any{"type": "string", "enum": jobStates}, "mine": map[string]any{"type": "boolean"}}), mcpReads, t.mcpBrowse("browsejobs"))132+ add("read_job", "Read one long task request by event id with its settled state and its answers oldest first: accepted, progress, result, cancel and error events.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsejob"))135133 add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {136134 return t.mcpManage(ctx, call, "listcallbacks")137135 })
@@ -230,9 +228,12 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
230228 add("publish_site", "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration.", mcp.Object(map[string]any{"event": mcpEvent, "label": map[string]any{"type": "string", "minLength": 1, "description": "Site label: your npub, or a named site label under your key. Defaults to your own site."}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": mcpText}, "description": "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]."}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape))231229 add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape))232230 add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey, request and content, plus room and optional root for a kind 9 room message, or root, root_kind and root_pubkey for a kind 1111 comment under another event. A room root is resolved to the thread root before signing."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape))233- add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind, inputs and optional room, output, params, bid in millisats, relays and expiration. A room-scoped job keeps its h tag on feedback and results; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "room": mcpRoomID, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))234- add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e, p, status and optional room, info, amount in millisats, invoice and content. For a room-scoped request, pass the same room so private feedback stays private.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape))235- add("job_result", "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional room, amount and invoice. When request is supplied, its room and inputs are copied into the result.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "room": mcpRoomID, "kind": map[string]any{"type": "integer", "minimum": event.KIND_JOB_RESULT_MIN, "maximum": event.KIND_JOB_RESULT_MAX}, "e": mcpHash, "p": mcpPubKey, "content": mcpText, "amount": mcpMsats, "invoice": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape))231+ add("request_job", "Ask one or more keys to take on a long task with a kind 43001 job request in a room. Pass room, assignees and content or subject, plus optional root (a thread root in the room) and expiration. The keys asked answer with accept_job, job_progress and job_result or job_error; cancel_job withdraws the request; read_job follows it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "assignees": mcpAssignees, "subject": mcpText, "content": mcpText, "root": mcpHash, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))232+ add("accept_job", "Take on a long task you were asked to do with a kind 43002 job accepted event. Pass request (the job request event) or e, p and room, plus optional content.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_ACCEPTED), mcpCheckJobAnswer(event.KIND_JOB_ACCEPTED), mcpJobAnswerShape))233+ add("job_progress", "Report progress on a long task you were asked to do with a kind 43003 job progress event, as often as needed. Pass request or e, p and room, plus content with the progress line.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_PROGRESS), mcpCheckJobAnswer(event.KIND_JOB_PROGRESS), mcpJobAnswerShape))234+ add("job_result", "Finish a long task with a kind 43004 job result. Pass request or e, p and room, content with the output and optional artifacts, each an e, a or r reference that clients link.", mcpJobAnswerSchema(true), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_RESULT), mcpCheckJobAnswer(event.KIND_JOB_RESULT), mcpJobAnswerShape))235+ add("job_error", "End a long task that failed with a kind 43006 job error. Pass request or e, p and room, plus content with the error message.", mcpJobAnswerSchema(false), mcpPublishes, t.mcpWrite(mcpBuildJobAnswer(event.KIND_JOB_ERROR), mcpCheckJobAnswer(event.KIND_JOB_ERROR), mcpJobAnswerShape))236+ add("cancel_job", "Cancel a long task you requested with a kind 43005 job cancel. Pass request or e and room, plus optional p (one of the keys asked) and content.", mcp.Object(map[string]any{"event": mcpEvent, "request": mcpJobEvent, "e": mcpHash, "room": mcpRoomID, "p": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobCancel, mcpCheckJobCancel, mcpJobCancelShape))236237 add("request_grant", "Ask your current grant operator for agent permission changes. Pass reason (1 to 500 Unicode characters) and changes. Kinds and rooms are added; repos and sites replace matching entries; wiki, jobs and rate replace their fields. Other fields stay unchanged. Sign the returned kind 1111 request and call again with event. Only the operator's signed kind 30392 replacement grants access; a + reaction does not.", mcpGrantRequestSchema(), mcpPublishes, t.mcpGrantRequest)237238 return registry, err238239 }
@@ -947,78 +948,48 @@ func mcpCheckRequest(e event.Event) error {
947948 return nil948949 }949950 950-// Long task builders. A request carries its inputs and terms as NIP-90951-// tags; feedback and results name the request and the requester.951+// Long task builders. A request names its room and the keys it asks;952+// every answer and cancel names the request, the requester and the room.953+954+// mcpJobAnswerSchema is the plain-field schema shared by the answer tools.955+// A result also takes artifacts.956+func mcpJobAnswerSchema(artifacts bool) map[string]any {957+ fields := map[string]any{"event": mcpEvent, "request": mcpJobEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "content": mcpText}958+ if artifacts {959+ fields["artifacts"] = mcpArtifacts960+ }961+ return mcp.Object(fields)962+}952963 953964 func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {954- kind := call.Int("kind")955- if !event.IsJobRequest(kind) {956- return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")957- }958- tags := [][]string{}959- if room := strings.TrimSpace(call.String("room")); room != "" {960- if !community.ValidRoomID(room) {961- return mcpUnsigned{}, errors.New("room must be a room id")962- }963- tags = append(tags, []string{"h", room})964- }965- if inputs, ok := call.Arguments["inputs"].([]any); ok {966- for _, raw := range inputs {967- input, _ := raw.(map[string]any)968- data, _ := input["data"].(string)969- kind, _ := input["type"].(string)970- relayHint, _ := input["relay"].(string)971- marker, _ := input["marker"].(string)972- if data == "" || !event.IsJobInputType(kind) {973- return mcpUnsigned{}, errors.New("each input needs data and a type of url, event, job or text")974- }975- if (kind == "event" || kind == "job") && !hex64(data) {976- return mcpUnsigned{}, errors.New("an input of type event or job must name an event id")977- }978- tag := []string{"i", data, kind}979- if relayHint != "" || marker != "" {980- tag = append(tag, relayHint)981- }982- if marker != "" {983- tag = append(tag, marker)984- }985- tags = append(tags, tag)965+ room := strings.TrimSpace(call.String("room"))966+ if !community.ValidRoomID(room) {967+ return mcpUnsigned{}, errors.New("room is required and must be a room id")968+ }969+ tags := [][]string{{"h", room}}970+ assignees, _ := call.Arguments["assignees"].([]any)971+ for _, raw := range assignees {972+ key, _ := raw.(string)973+ if !hex64(key) {974+ return mcpUnsigned{}, errors.New("assignees must be hex public keys")986975 }976+ tags = append(tags, []string{"p", key})987977 }988- if output := strings.TrimSpace(call.String("output")); output != "" {989- tags = append(tags, []string{"output", output})978+ if len(assignees) == 0 {979+ return mcpUnsigned{}, errors.New("assignees must name at least one key to ask")990980 }991- if params, ok := call.Arguments["params"].(map[string]any); ok {992- keys := make([]string, 0, len(params))993- for key := range params {994- keys = append(keys, key)995- }996- sort.Strings(keys)997- for _, key := range keys {998- value, ok := params[key].(string)999- if key == "" || !ok {1000- return mcpUnsigned{}, errors.New("params must map names to string values")1001- }1002- tags = append(tags, []string{"param", key, value})1003- }981+ subject, content := strings.TrimSpace(call.String("subject")), call.String("content")982+ if subject == "" && strings.TrimSpace(content) == "" {983+ return mcpUnsigned{}, errors.New("content or subject is required")1004984 }1005- if _, present := call.Arguments["bid"]; present {1006- bid := call.Int("bid")1007- if bid < 0 {1008- return mcpUnsigned{}, errors.New("bid must be an amount in millisats")1009- }1010- tags = append(tags, []string{"bid", strconv.Itoa(bid)})985+ if subject != "" {986+ tags = append(tags, []string{"subject", subject})1011987 }1012- if relays, ok := call.Arguments["relays"].([]any); ok {1013- tag := []string{"relays"}1014- for _, value := range relays {1015- if text, _ := value.(string); strings.TrimSpace(text) != "" {1016- tag = append(tag, strings.TrimSpace(text))1017- }1018- }1019- if len(tag) > 1 {1020- tags = append(tags, tag)988+ if root := call.String("root"); root != "" {989+ if !hex64(root) {990+ return mcpUnsigned{}, errors.New("root must be an event id")1021991 }992+ tags = append(tags, []string{"e", root, "", "root"})1022993 }1023994 if expiration := call.Int("expiration"); expiration > 0 {1024995 if int64(expiration) <= time.Now().Unix() {
@@ -1026,118 +997,128 @@ func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {
1026997 }1027998 tags = append(tags, []string{"expiration", strconv.Itoa(expiration)})1028999 }1029- return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: ""}, nil1000+ return mcpUnsigned{Kind: event.KIND_JOB_REQUEST, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil10301001 }10311002 1032-// mcpJobAmount appends the amount tag a provider asks to be paid, with its1033-// invoice when one is given.1034-func mcpJobAmount(call mcp.Call, tags [][]string) ([][]string, error) {1035- if _, present := call.Arguments["amount"]; !present {1036- return tags, nil1003+// mcpJobRequestArgument reads the request event an answer tool was given.1004+func mcpJobRequestArgument(call mcp.Call) (event.Event, bool, error) {1005+ raw, ok := call.Arguments["request"].(map[string]any)1006+ if !ok {1007+ return event.Event{}, false, nil10371008 }1038- amount := call.Int("amount")1039- if amount < 0 {1040- return nil, errors.New("amount must be in millisats")1009+ var request event.Event1010+ encoded, err := json.Marshal(raw)1011+ if err == nil {1012+ err = json.Unmarshal(encoded, &request)10411013 }1042- tag := []string{"amount", strconv.Itoa(amount)}1043- if invoice := strings.TrimSpace(call.String("invoice")); invoice != "" {1044- tag = append(tag, invoice)1014+ if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {1015+ return event.Event{}, false, errors.New("request must be a kind 43001 job request event with id and pubkey")10451016 }1046- return append(tags, tag), nil1017+ return request, true, nil10471018 }10481019 1049-func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) {1050- request, requester, status := call.String("e"), call.String("p"), call.String("status")1051- if !hex64(request) || !hex64(requester) || !event.IsJobFeedbackStatus(status) {1052- return mcpUnsigned{}, errors.New("e, p and status are required; status must be payment-required, processing, error, success or partial")1053- }1054- statusTag := []string{"status", status}1055- if info := strings.TrimSpace(call.String("info")); info != "" {1056- statusTag = append(statusTag, info)1057- }1058- tags := [][]string{statusTag, {"e", request}, {"p", requester}}1059- if room := strings.TrimSpace(call.String("room")); room != "" {1060- if !community.ValidRoomID(room) {1061- return mcpUnsigned{}, errors.New("room must be a room id")1062- }1063- tags = append(tags, []string{"h", room})1064- }1065- tags, err := mcpJobAmount(call, tags)1020+// mcpJobTarget resolves the request an answer names, from the request1021+// event or from e, p and room, and checks that the two agree.1022+func mcpJobTarget(call mcp.Call) (id, requester, room string, err error) {1023+ id, requester, room = call.String("e"), call.String("p"), strings.TrimSpace(call.String("room"))1024+ request, ok, err := mcpJobRequestArgument(call)10661025 if err != nil {1067- return mcpUnsigned{}, err1026+ return "", "", "", err10681027 }1069- return mcpUnsigned{Kind: event.KIND_JOB_FEEDBACK, CreatedAt: time.Now().Unix(), Tags: tags, Content: call.String("content")}, nil1070-}1071-1072-func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {1073- var request event.Event1074- hasRequest := false1075- if raw, ok := call.Arguments["request"].(map[string]any); ok {1076- encoded, err := json.Marshal(raw)1077- if err == nil {1078- err = json.Unmarshal(encoded, &request)1079- }1080- if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {1081- return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5127 or 5129 to 5999")1082- }1083- hasRequest = true1084- }1085- kind, id, requester, content := call.Int("kind"), call.String("e"), call.String("p"), call.String("content")1086- if hasRequest {1087- if kind == 0 {1088- kind = event.JobResultKind(request.Kind)1089- }1028+ if ok {10901029 if id == "" {10911030 id = request.ID10921031 }10931032 if requester == "" {10941033 requester = request.PubKey10951034 }1096- if kind != event.JobResultKind(request.Kind) || id != request.ID || requester != request.PubKey {1097- return mcpUnsigned{}, errors.New("kind, e and p must match the request: its kind plus 1000, its id and its author")1035+ if room == "" {1036+ room = event.Tag(request, "h")1037+ }1038+ if id != request.ID || requester != request.PubKey || room != event.Tag(request, "h") {1039+ return "", "", "", errors.New("e, p and room must match the request: its id, its author and its h tag")10981040 }10991041 }1100- if !event.IsJobResult(kind) || !hex64(id) || !hex64(requester) {1101- return mcpUnsigned{}, errors.New("request, or kind (the request kind plus 1000), e and p, are required")1102- }1103- if strings.TrimSpace(content) == "" {1104- return mcpUnsigned{}, errors.New("content is required")1042+ if !hex64(id) || !hex64(requester) || !community.ValidRoomID(room) {1043+ return "", "", "", errors.New("request, or e (the request id), p (the requester) and room, are required")11051044 }1106- tags := [][]string{}1107- if hasRequest {1108- if room := event.Tag(request, "h"); room != "" {1109- tags = append(tags, []string{"h", room})1110- }1111- canonical, err := event.Canonical(request)1045+ return id, requester, room, nil1046+}1047+1048+// mcpBuildJobAnswer builds an accepted, progress, result or error event.1049+// Progress, results and errors need content; a result may add artifacts.1050+func mcpBuildJobAnswer(kind int) func(mcp.Call) (mcpUnsigned, error) {1051+ return func(call mcp.Call) (mcpUnsigned, error) {1052+ id, requester, room, err := mcpJobTarget(call)11121053 if err != nil {11131054 return mcpUnsigned{}, err11141055 }1115- tags = append(tags, []string{"request", string(canonical)})1116- }1117- if room := strings.TrimSpace(call.String("room")); room != "" {1118- if !community.ValidRoomID(room) {1119- return mcpUnsigned{}, errors.New("room must be a room id")1120- }1121- if hasRequest && event.Tag(request, "h") != room {1122- return mcpUnsigned{}, errors.New("room must match the request")1056+ content := call.String("content")1057+ if kind != event.KIND_JOB_ACCEPTED && strings.TrimSpace(content) == "" {1058+ return mcpUnsigned{}, errors.New("content is required")11231059 }1124- if !hasRequest {1125- tags = append(tags, []string{"h", room})1060+ tags := [][]string{{"h", room}, {"e", id}, {"p", requester}}1061+ if artifacts, ok := call.Arguments["artifacts"].([]any); ok && kind == event.KIND_JOB_RESULT {1062+ for _, raw := range artifacts {1063+ artifact, _ := raw.(map[string]any)1064+ name, _ := artifact["type"].(string)1065+ value, _ := artifact["value"].(string)1066+ if !mcpJobArtifactOK(name, strings.TrimSpace(value)) {1067+ return mcpUnsigned{}, errors.New("each artifact needs a type of e (an event id), a (a kind:pubkey:identifier coordinate) or r (an http or https URL) and a matching value")1068+ }1069+ tags = append(tags, []string{name, strings.TrimSpace(value)})1070+ }11261071 }1072+ return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil11271073 }1128- tags = append(tags, []string{"e", id})1129- if hasRequest {1130- for _, tag := range request.Tags {1131- if len(tag) > 1 && tag[0] == "i" {1132- tags = append(tags, append([]string(nil), tag...))1133- }1074+}1075+1076+// mcpJobArtifactOK reports whether one artifact reference is well formed.1077+func mcpJobArtifactOK(name, value string) bool {1078+ switch name {1079+ case "e":1080+ return hex64(value)1081+ case "a":1082+ parts := strings.SplitN(value, ":", 3)1083+ if len(parts) != 3 || !hex64(parts[1]) {1084+ return false11341085 }1086+ _, err := strconv.Atoi(parts[0])1087+ return err == nil1088+ case "r":1089+ return strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://")11351090 }1136- tags, err := mcpJobAmount(call, append(tags, []string{"p", requester}))1091+ return false1092+}1093+1094+func mcpBuildJobCancel(call mcp.Call) (mcpUnsigned, error) {1095+ id, room := call.String("e"), strings.TrimSpace(call.String("room"))1096+ request, ok, err := mcpJobRequestArgument(call)11371097 if err != nil {11381098 return mcpUnsigned{}, err11391099 }1140- return mcpUnsigned{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil1100+ if ok {1101+ if id == "" {1102+ id = request.ID1103+ }1104+ if room == "" {1105+ room = event.Tag(request, "h")1106+ }1107+ if id != request.ID || room != event.Tag(request, "h") {1108+ return mcpUnsigned{}, errors.New("e and room must match the request: its id and its h tag")1109+ }1110+ }1111+ if !hex64(id) || !community.ValidRoomID(room) {1112+ return mcpUnsigned{}, errors.New("request, or e (the request id) and room, are required")1113+ }1114+ tags := [][]string{{"h", room}, {"e", id}}1115+ if assignee := call.String("p"); assignee != "" {1116+ if !hex64(assignee) {1117+ return mcpUnsigned{}, errors.New("p must be a hex public key")1118+ }1119+ tags = append(tags, []string{"p", assignee})1120+ }1121+ return mcpUnsigned{Kind: event.KIND_JOB_CANCEL, CreatedAt: time.Now().Unix(), Tags: tags, Content: call.String("content")}, nil11411122 }11421123 11431124 // mcpJobShape reports the gate's shape check without its NIP-01 prefix, so
@@ -1158,25 +1139,42 @@ func mcpCheckJobRequest(e event.Event) error {
11581139 return errors.New("the expiration tag must be a Unix time in the future")11591140 }11601141 }1142+ if strings.TrimSpace(e.Content) == "" && strings.TrimSpace(event.Tag(e, "subject")) == "" {1143+ return errors.New("content or a subject tag is required")1144+ }11611145 return mcpJobShape(e)11621146 }11631147 1164-func mcpCheckJobFeedback(e event.Event) error {1165- if e.Kind != event.KIND_JOB_FEEDBACK {1166- return fmt.Errorf("kind %d is not job feedback", e.Kind)1148+func mcpCheckJobAnswer(kind int) func(event.Event) error {1149+ return func(e event.Event) error {1150+ if e.Kind != kind {1151+ return fmt.Errorf("kind %d is not a %s", e.Kind, gates.JobKindName(kind))1152+ }1153+ if kind != event.KIND_JOB_ACCEPTED && strings.TrimSpace(e.Content) == "" {1154+ return errors.New("content must not be empty")1155+ }1156+ if kind == event.KIND_JOB_RESULT {1157+ first := true1158+ for _, tag := range e.Tags {1159+ if len(tag) < 2 {1160+ continue1161+ }1162+ if tag[0] == "e" && first {1163+ first = false1164+ continue1165+ }1166+ if (tag[0] == "e" || tag[0] == "a" || tag[0] == "r") && !mcpJobArtifactOK(tag[0], tag[1]) {1167+ return fmt.Errorf("the %s artifact tag %q is not an event id, a coordinate or an http or https URL", tag[0], tag[1])1168+ }1169+ }1170+ }1171+ return mcpJobShape(e)11671172 }1168- return mcpJobShape(e)11691173 }11701174 1171-func mcpCheckJobResult(e event.Event) error {1172- if !event.IsJobResult(e.Kind) {1173- return fmt.Errorf("kind %d is not a job result", e.Kind)1174- }1175- if request := event.Tag(e, "request"); request != "" && (!json.Valid([]byte(request)) || !strings.HasPrefix(strings.TrimSpace(request), "{")) {1176- return errors.New("the request tag must carry the job request event as JSON")1177- }1178- if strings.TrimSpace(e.Content) == "" {1179- return errors.New("content must not be empty")1175+func mcpCheckJobCancel(e event.Event) error {1176+ if !event.IsJobCancel(e.Kind) {1177+ return fmt.Errorf("kind %d is not a job cancel", e.Kind)11801178 }11811179 return mcpJobShape(e)11821180 }
M internal/daemon/site_snapshot_jobs_test.go +4 -8
@@ -7,7 +7,6 @@ import (
77 "time"88 99 "github.com/FelineStateMachine/tinyrelay/internal/event"10- "github.com/FelineStateMachine/tinyrelay/internal/mcp"1110 "github.com/FelineStateMachine/tinyrelay/internal/sites"1211 )1312
@@ -19,7 +18,7 @@ func TestSiteSnapshotStaysOutOfJobReads(t *testing.T) {
1918 paths := [][]string{{"path", "/index.html", strings.Repeat("a", 64)}}2019 tags := append(paths, []string{"x", sites.Aggregate(paths), "aggregate"}, []string{"a", "15128:" + owner + ":"})2120 snapshot := wikiPublish(t, tenant, wikiOwnerSecret, event.KIND_SITE_SNAPSHOT, now, "", tags...)22- job := wikiPublish(t, tenant, wikiOwnerSecret, 5001, now-1, "", []string{"i", "hello", "text"})21+ job := wikiPublish(t, tenant, wikiOwnerSecret, event.KIND_JOB_REQUEST, now-1, "hello", []string{"h", jobTestRoom}, []string{"p", owner})2322 2423 result, err := tenant.browseJobs(ctx, owner, jobBrowseRequest{Limit: 1})2524 if err != nil {
@@ -37,12 +36,9 @@ func TestSiteSnapshotStaysOutOfJobReads(t *testing.T) {
3736 }3837 }3938 40-func TestSiteSnapshotCannotBuildJobRequest(t *testing.T) {41- if err := mcp.Validate(mcpJobKind, float64(event.KIND_SITE_SNAPSHOT)); err == nil {42- t.Fatal("site snapshot kind accepted by the advertised job schema")43- }44- if _, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"kind": float64(event.KIND_SITE_SNAPSHOT)}}); err == nil {45- t.Fatal("site snapshot kind accepted by the job request builder")39+func TestSiteSnapshotIsNotAJobRequest(t *testing.T) {40+ if event.IsJobKind(event.KIND_SITE_SNAPSHOT) {41+ t.Fatal("site snapshot kind classified as a long task")4642 }4743 if err := mcpCheckJobRequest(event.Event{Kind: event.KIND_SITE_SNAPSHOT}); err == nil {4844 t.Fatal("site snapshot accepted by the signed job request checker")
M internal/daemon/webpush_triggers.go +23 -23
@@ -14,6 +14,7 @@ import (
1414 "time"1515 1616 "github.com/FelineStateMachine/tinyrelay/internal/event"17+ "github.com/FelineStateMachine/tinyrelay/internal/gates"1718 "github.com/FelineStateMachine/tinyrelay/internal/storage"1819 )1920
@@ -99,7 +100,7 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
99100 // A job result, or feedback that needs the requester's attention,100101 // reaches the requester as a mention.101102 if status, ok := jobNotice(e); ok {102- body := "job " + t.jobRequestKind(ctx, e) + " " + status103+ body := t.jobLabel(ctx, e) + " " + status103104 for _, recipient := range pushRecipients(e) {104105 notices = append(notices, pushNotice{recipient: recipient, category: pushMentions, body: body, url: base + "/e/" + e.ID})105106 }
@@ -160,36 +161,35 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
160161 return notices161162 }162163 163-// jobNotice reports whether a long task event wakes its requester and with164-// which status: every result does, and feedback does when it asks for165-// payment or reports an error. Progress and success feedback stay quiet,166-// since the result follows.164+// jobNotice reports whether a long task event wakes its requester and how165+// it ended: a result reads as done and an error as failed. Accepted and166+// progress answers stay quiet, since the end follows.167167 func jobNotice(e event.Event) (string, bool) {168- if event.IsJobResult(e.Kind) {169- return "result", true170- }171- if e.Kind != event.KIND_JOB_FEEDBACK {172- return "", false168+ switch e.Kind {169+ case event.KIND_JOB_RESULT:170+ return "done", true171+ case event.KIND_JOB_ERROR:172+ return "failed", true173173 }174- status := event.Tag(e, "status")175- return status, status == "error" || status == "payment-required"174+ return "", false176175 }177176 178-// jobRequestKind names the request kind an answer belongs to: a result179-// says it by its own kind, and feedback by the request the relay holds.180-func (t *Tenant) jobRequestKind(ctx context.Context, e event.Event) string {181- if event.IsJobResult(e.Kind) {182- return strconv.Itoa(e.Kind - 1000)183- }184- id := event.Tag(e, "e")177+// jobLabel names the request an answer belongs to: its subject when the178+// relay holds the request and it has one, otherwise a short form of the179+// request id.180+func (t *Tenant) jobLabel(ctx context.Context, e event.Event) string {181+ id := gates.JobRequestID(e)185182 if len(id) != 64 {186- return strconv.Itoa(e.Kind)183+ return "task"187184 }185+ label := id[:8]188186 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})189- if err != nil || len(rows.Events) == 0 || !event.IsJobRequest(rows.Events[0].Kind) {190- return strconv.Itoa(e.Kind)187+ if err == nil && len(rows.Events) == 1 && event.IsJobRequest(rows.Events[0].Kind) {188+ if subject := strings.TrimSpace(event.Tag(rows.Events[0], "subject")); subject != "" {189+ label = excerpt(subject)190+ }191191 }192- return strconv.Itoa(rows.Events[0].Kind)192+ return "task " + label193193 }194194 195195 // pushRecipients lists the addressed keys other than the author, bounded so
M internal/event/kinds.go +27 -49
@@ -64,63 +64,41 @@ const (
6464 KIND_AGENT_GRANT = 303926565 )6666 67-// NIP-90 long tasks use request kinds 5000 to 5127 and 5129 to 5999.68-// Each request is answered by a result kind 1000 higher and by feedback69-// of kind 7000. Kind 5128 belongs to NIP-5A site snapshots.67+// Long tasks use the six job kinds Buzz reserves. A requester publishes a68+// request; the keys it asks answer while they work and finish with a result69+// or an error; the requester may cancel. Kind 5128 belongs to NIP-5A site70+// snapshots and is unrelated.7071 const (71- KIND_JOB_REQUEST_MIN = 500072- KIND_JOB_REQUEST_MAX = 599973- KIND_JOB_RESULT_MIN = 600074- KIND_JOB_RESULT_MAX = 699975- KIND_JOB_FEEDBACK = 700072+ KIND_JOB_REQUEST = 4300173+ KIND_JOB_ACCEPTED = 4300274+ KIND_JOB_PROGRESS = 4300375+ KIND_JOB_RESULT = 4300476+ KIND_JOB_CANCEL = 4300577+ KIND_JOB_ERROR = 430067678 )7779 78-// JobFeedbackStatuses is the NIP-90 vocabulary of the feedback status tag.79-var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "success", "partial"}80+// IsJobRequest reports whether kind is a long-task request.81+func IsJobRequest(kind int) bool { return kind == KIND_JOB_REQUEST }8082 81-// JobInputTypes is the NIP-90 vocabulary of the i tag's input type.82-var JobInputTypes = []string{"url", "event", "job", "text"}83+// IsJobResult reports whether kind is a long-task result.84+func IsJobResult(kind int) bool { return kind == KIND_JOB_RESULT }8385 84-// IsJobRequest reports whether kind belongs to long-task requests.85-// NIP-5A site snapshots have their own admission and manifest rules.86-func IsJobRequest(kind int) bool {87- return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX && kind != KIND_SITE_SNAPSHOT86+// IsJobAnswer reports whether kind is one of the answers an asked key87+// publishes: accepted, progress, result or error.88+func IsJobAnswer(kind int) bool {89+ return kind == KIND_JOB_ACCEPTED || kind == KIND_JOB_PROGRESS || kind == KIND_JOB_RESULT || kind == KIND_JOB_ERROR8890 }8991 90-// IsJobResult reports whether kind is a NIP-90 result kind.91-func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }92+// IsJobCancel reports whether kind is a requester's cancel.93+func IsJobCancel(kind int) bool { return kind == KIND_JOB_CANCEL }9294 93-// IsJobKind reports whether the kind is a job request, result or feedback.94-func IsJobKind(kind int) bool {95- return IsJobRequest(kind) || IsJobResult(kind) || kind == KIND_JOB_FEEDBACK96-}97-98-// JobResultKind returns the result kind for a request kind, or 0 for other99-// kinds.100-func JobResultKind(request int) int {101- if !IsJobRequest(request) {102- return 0103- }104- return request + 100095+// IsJobTerminal reports whether kind ends a long task: result, cancel or96+// error.97+func IsJobTerminal(kind int) bool {98+ return kind == KIND_JOB_RESULT || kind == KIND_JOB_CANCEL || kind == KIND_JOB_ERROR10599 }106100 107-// IsJobFeedbackStatus reports whether status is in the NIP-90 feedback108-// vocabulary.109-func IsJobFeedbackStatus(status string) bool {110- for _, known := range JobFeedbackStatuses {111- if known == status {112- return true113- }114- }115- return false116-}117-118-// IsJobInputType reports whether kind is a supported NIP-90 input type.119-func IsJobInputType(kind string) bool {120- for _, known := range JobInputTypes {121- if known == kind {122- return true123- }124- }125- return false101+// IsJobKind reports whether kind is any of the six long-task kinds.102+func IsJobKind(kind int) bool {103+ return IsJobRequest(kind) || IsJobAnswer(kind) || IsJobCancel(kind)126104 }
M internal/event/kinds_test.go +15 -17
@@ -4,27 +4,25 @@ import "testing"
44 55 func TestJobKindClassification(t *testing.T) {66 tests := []struct {7- kind int8- request bool9- result bool10- job bool11- answer int7+ kind int8+ request, answer, cancel, terminal, result bool129 }{13- {kind: 4999},14- {kind: 5000, request: true, job: true, answer: 6000},15- {kind: 5127, request: true, job: true, answer: 6127},10+ {kind: 5000},1611 {kind: KIND_SITE_SNAPSHOT},17- {kind: 5129, request: true, job: true, answer: 6129},18- {kind: 5999, request: true, job: true, answer: 6999},19- {kind: 6000, result: true, job: true},20- {kind: 6128, result: true, job: true},21- {kind: 6999, result: true, job: true},22- {kind: KIND_JOB_FEEDBACK, job: true},23- {kind: 7001},12+ {kind: 7000},13+ {kind: 43000},14+ {kind: KIND_JOB_REQUEST, request: true},15+ {kind: KIND_JOB_ACCEPTED, answer: true},16+ {kind: KIND_JOB_PROGRESS, answer: true},17+ {kind: KIND_JOB_RESULT, answer: true, terminal: true, result: true},18+ {kind: KIND_JOB_CANCEL, cancel: true, terminal: true},19+ {kind: KIND_JOB_ERROR, answer: true, terminal: true},20+ {kind: 43007},2421 }2522 for _, tt := range tests {26- if IsJobRequest(tt.kind) != tt.request || IsJobResult(tt.kind) != tt.result || IsJobKind(tt.kind) != tt.job || JobResultKind(tt.kind) != tt.answer {27- t.Errorf("kind %d: request=%v result=%v job=%v answer=%d; want request=%v result=%v job=%v answer=%d", tt.kind, IsJobRequest(tt.kind), IsJobResult(tt.kind), IsJobKind(tt.kind), JobResultKind(tt.kind), tt.request, tt.result, tt.job, tt.answer)23+ job := tt.request || tt.answer || tt.cancel24+ if IsJobRequest(tt.kind) != tt.request || IsJobAnswer(tt.kind) != tt.answer || IsJobCancel(tt.kind) != tt.cancel || IsJobTerminal(tt.kind) != tt.terminal || IsJobResult(tt.kind) != tt.result || IsJobKind(tt.kind) != job {25+ t.Errorf("kind %d: request=%v answer=%v cancel=%v terminal=%v result=%v job=%v", tt.kind, IsJobRequest(tt.kind), IsJobAnswer(tt.kind), IsJobCancel(tt.kind), IsJobTerminal(tt.kind), IsJobResult(tt.kind), IsJobKind(tt.kind))2826 }2927 }3028 }
M internal/gates/gates.go +4 -4
M internal/gates/jobs.go +105 -60
@@ -1,74 +1,114 @@
11 package gates22 3-// Long tasks follow NIP-90: a member or agent publishes a job request4-// (kind 5000 to 5127 or 5129 to 5999), and a serving agent or member answers it with5-// feedback (kind 7000) and a result (the request kind plus 1000). The relay6-// keeps every event signed by its author; it only checks that requests,7-// feedback and results carry the tags the protocol needs, that they are8-// published by members or agents, and that an agent's answers name a9-// request the relay holds.3+// Long tasks use the six job kinds Buzz reserves. A member or agent4+// publishes a request (kind 43001) in a room, naming the keys it asks in p5+// tags. An asked key answers with accepted (43002), progress (43003) and6+// finally a result (43004) or an error (43006); the requester may cancel7+// (43005). The relay keeps every event signed by its author; it checks8+// that requests, answers and cancels carry the tags the protocol needs,9+// that they come from members or agents, and that every answer or cancel10+// names a request the relay holds, from a key the request allows.1011 1112 import (1213 "context"1314 "errors"1415 "fmt"15- "strconv"1616 17+ "github.com/FelineStateMachine/tinyrelay/internal/community"1718 "github.com/FelineStateMachine/tinyrelay/internal/event"1819 "github.com/FelineStateMachine/tinyrelay/internal/policy"1920 "github.com/FelineStateMachine/tinyrelay/internal/relay"2021 "github.com/FelineStateMachine/tinyrelay/internal/storage"2122 )2223 23-// JobShape checks the tags of a job request, result or feedback event. It24+// JobShape checks the tags of a job request, answer or cancel event. It2425 // returns nil for other event kinds.2526 func JobShape(e event.Event) error {2627 switch {2728 case event.IsJobRequest(e.Kind):2829 return jobRequestShape(e)29- case event.IsJobResult(e.Kind):30- return jobReplyShape(e, "job result")31- case e.Kind == event.KIND_JOB_FEEDBACK:32- if err := jobReplyShape(e, "job feedback"); err != nil {33- return err34- }35- if !event.IsJobFeedbackStatus(event.Tag(e, "status")) {36- return errors.New("invalid: job feedback status must be payment-required, processing, error, success or partial")37- }30+ case event.IsJobAnswer(e.Kind):31+ return jobAnswerShape(e, JobKindName(e.Kind))32+ case event.IsJobCancel(e.Kind):33+ return jobCancelShape(e)3834 }3935 return nil4036 }4137 38+// JobKindName names a job kind the way messages and cards refer to it.39+func JobKindName(kind int) string {40+ switch kind {41+ case event.KIND_JOB_REQUEST:42+ return "job request"43+ case event.KIND_JOB_ACCEPTED:44+ return "job accepted"45+ case event.KIND_JOB_PROGRESS:46+ return "job progress"47+ case event.KIND_JOB_RESULT:48+ return "job result"49+ case event.KIND_JOB_CANCEL:50+ return "job cancel"51+ case event.KIND_JOB_ERROR:52+ return "job error"53+ }54+ return "job event"55+}56+57+// JobRequestID returns the request an answer or cancel names: its first e58+// tag. Later e tags on a result reference artifacts.59+func JobRequestID(e event.Event) string {60+ return event.Tag(e, "e")61+}62+4263 func jobRequestShape(e event.Event) error {64+ if !community.ValidRoomID(event.Tag(e, "h")) {65+ return errors.New("invalid: job request must name its room in an h tag")66+ }67+ asked := 04368 for _, tag := range e.Tags {44- if len(tag) == 0 || tag[0] != "i" {69+ if len(tag) < 2 {4570 continue4671 }47- if len(tag) < 3 || !event.IsJobInputType(tag[2]) {48- return errors.New("invalid: job input i tag needs data and a type of url, event, job or text")49- }50- if (tag[2] == "event" || tag[2] == "job") && !jobID(tag[1]) {51- return errors.New("invalid: job input of type event or job must name an event id")72+ switch tag[0] {73+ case "p":74+ if !jobID(tag[1]) {75+ return errors.New("invalid: job request p tags must be hex public keys")76+ }77+ asked++78+ case "e":79+ if !jobID(tag[1]) {80+ return errors.New("invalid: job request e tag must name the thread root event id")81+ }5282 }5383 }54- if hasTag(e, "encrypted") && !jobID(event.Tag(e, "p")) {55- return errors.New("invalid: encrypted job request must name the service provider in a p tag")56- }57- if bid := event.Tag(e, "bid"); bid != "" && !jobAmount(bid) {58- return errors.New("invalid: job bid must be an amount in millisats")84+ if asked == 0 {85+ return errors.New("invalid: job request must name at least one asked key in a p tag")5986 }6087 return nil6188 }6289 63-func jobReplyShape(e event.Event, what string) error {64- if !jobID(event.Tag(e, "e")) {90+func jobAnswerShape(e event.Event, what string) error {91+ if !jobID(JobRequestID(e)) {6592 return fmt.Errorf("invalid: %s must name the job request in an e tag", what)6693 }6794 if !jobID(event.Tag(e, "p")) {6895 return fmt.Errorf("invalid: %s must name the requester in a p tag", what)6996 }70- if amount := event.Tag(e, "amount"); amount != "" && !jobAmount(amount) {71- return fmt.Errorf("invalid: %s amount must be in millisats", what)97+ if !community.ValidRoomID(event.Tag(e, "h")) {98+ return fmt.Errorf("invalid: %s must keep the request's room in an h tag", what)99+ }100+ return nil101+}102+103+func jobCancelShape(e event.Event) error {104+ if !jobID(JobRequestID(e)) {105+ return errors.New("invalid: job cancel must name the job request in an e tag")106+ }107+ if !community.ValidRoomID(event.Tag(e, "h")) {108+ return errors.New("invalid: job cancel must keep the request's room in an h tag")109+ }110+ if p := event.Tag(e, "p"); p != "" && !jobID(p) {111+ return errors.New("invalid: job cancel p tag must be a hex public key")72112 }73113 return nil74114 }
@@ -90,54 +130,59 @@ func jobWriter(e event.Event, a policy.Access) error {
90130 return nil91131 }92132 93-// jobReply checks a result or feedback against the request it names. When94-// the relay holds the request, the result kind must match it and the p tag95-// must name its author. An agent may only answer a request the relay holds96-// and the agent may read; a member may also answer requests made elsewhere.97-func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64, agent bool) error {98- if !event.IsJobResult(e.Kind) && e.Kind != event.KIND_JOB_FEEDBACK {133+// jobReply checks an answer or cancel against the request it names. The134+// relay must hold the request and the publisher must be able to read it.135+// An answer must come from a key the request asked, name the requester in136+// p and keep the request's room; a cancel must come from the requester.137+func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64) error {138+ if !event.IsJobAnswer(e.Kind) && !event.IsJobCancel(e.Kind) {99139 return nil100140 }101- missing := errors.New("restricted: agent grant allows job results and feedback only for requests this relay holds")141+ what := JobKindName(e.Kind)142+ missing := fmt.Errorf("restricted: %s must name a request this relay holds", what)102143 if g.cfg.Store == nil {103- if agent {104- return missing105- }106- return nil144+ return missing107145 }108- result, err := g.cfg.Store.Query(ctx, event.Filter{IDs: []string{event.Tag(e, "e")}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1})146+ result, err := g.cfg.Store.Query(ctx, event.Filter{IDs: []string{JobRequestID(e)}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1})109147 if err != nil {110148 return fmt.Errorf("check job request: %w", err)111149 }112150 if len(result.Events) == 0 || !event.IsJobRequest(result.Events[0].Kind) {113- if agent {114- return missing115- }116- return nil151+ return missing117152 }118153 request := result.Events[0]119- if agent && !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) {154+ if !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) {120155 return missing121156 }122- if event.IsJobResult(e.Kind) && e.Kind != event.JobResultKind(request.Kind) {123- return fmt.Errorf("invalid: job result kind %d does not answer a kind %d request", e.Kind, request.Kind)157+ if event.Tag(e, "h") != event.Tag(request, "h") {158+ return fmt.Errorf("invalid: %s must keep the request's room h tag", what)124159 }125- if room := event.Tag(request, "h"); room != "" && event.Tag(e, "h") != room {126- return errors.New("invalid: job result or feedback must keep the request's room h tag")160+ if event.IsJobCancel(e.Kind) {161+ if e.PubKey != request.PubKey {162+ return errors.New("restricted: only the requester may cancel a long task")163+ }164+ return nil127165 }128166 if event.Tag(e, "p") != request.PubKey {129- return errors.New("invalid: job result or feedback p tag must name the requester")167+ return fmt.Errorf("invalid: %s p tag must name the requester", what)168+ }169+ if !containsValue(event.TagValues(request, "p"), e.PubKey) {170+ return fmt.Errorf("restricted: %s must come from a key the request asked", what)130171 }131172 return nil132173 }133174 134-func jobID(value string) bool {135- return len(value) == 64 && isLowerHex(value)175+func containsValue(values []string, want string) bool {176+ for _, value := range values {177+ if value == want {178+ return true179+ }180+ }181+ return false136182 }137183 138-func jobAmount(value string) bool {139- _, err := strconv.ParseUint(value, 10, 64)140- return err == nil184+func jobID(value string) bool {185+ return len(value) == 64 && isLowerHex(value)141186 }142187 143188 func isLowerHex(value string) bool {
M internal/gates/jobs_test.go +120 -67
@@ -16,6 +16,9 @@ import (
1616 1717 const jobMemberSecret = "0000000000000000000000000000000000000000000000000000000000000005"1818 19+// jobRoom is the fixture's slug room, which every member may write to.20+const jobRoom = "general"21+1922 // jobFixture adds a human member who requests jobs to the agent fixture.2023 type jobFixture struct {2124 agentFixture
@@ -33,22 +36,32 @@ func newJobFixture(t *testing.T) jobFixture {
3336 return jobFixture{agentFixture: f, member: member}3437 }3538 36-// request publishes a job request from the member and stores it, so37-// answers can name a request the relay holds.38-func (f jobFixture) request(t *testing.T, kind int, createdAt int64) event.Event {39+// request publishes a job request from the member asking the given keys40+// and stores it, so answers can name a request the relay holds.41+func (f jobFixture) request(t *testing.T, createdAt int64, asked ...string) event.Event {3942 t.Helper()4043 ctx := context.Background()41- e := signed(t, jobMemberSecret, kind, createdAt, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")44+ tags := [][]string{{"h", jobRoom}, {"subject", "Summarize the notes"}}45+ for _, key := range asked {46+ tags = append(tags, []string{"p", key})47+ }48+ e := signed(t, jobMemberSecret, event.KIND_JOB_REQUEST, createdAt, tags, "Summarize this week's notes.")4249 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {4350 t.Fatalf("member request rejected: %v", err)4451 }52+ f.save(t, e)53+ return e54+}55+56+func (f jobFixture) save(t *testing.T, e event.Event) {57+ t.Helper()58+ ctx := context.Background()4559 if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {4660 _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow})4761 return err4862 }); err != nil {4963 t.Fatal(err)5064 }51- return e5265 }5366 5467 func expectError(t *testing.T, err error, prefix, fragment string) {
@@ -62,57 +75,96 @@ func TestJobShapeRefusesMalformedEvents(t *testing.T) {
6275 f := newJobFixture(t)6376 ctx := context.Background()6477 session := relay.Session{PubKeys: []string{f.member}}65- request := f.request(t, 5001, agentNow)78+ request := f.request(t, agentNow, f.member)6679 cases := []struct {6780 name string6881 kind int6982 tags [][]string7083 fragment string7184 }{72- {"input without a type", 5001, [][]string{{"i", "hello"}}, "url, event, job or text"},73- {"input with an unknown type", 5001, [][]string{{"i", "hello", "voice"}}, "url, event, job or text"},74- {"event input without an id", 5001, [][]string{{"i", "abc", "event"}}, "event id"},75- {"encrypted without a provider", 5001, [][]string{{"encrypted"}}, "p tag"},76- {"bid that is not an amount", 5001, [][]string{{"bid", "ten"}}, "millisats"},77- {"result without a request", 6001, [][]string{{"p", f.member}}, "e tag"},78- {"result without a requester", 6001, [][]string{{"e", request.ID}}, "p tag"},79- {"result with a bad amount", 6001, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "-1"}}, "millisats"},80- {"result of the wrong kind", 6002, [][]string{{"e", request.ID}, {"p", f.member}}, "does not answer"},81- {"result naming the wrong requester", 6001, [][]string{{"e", request.ID}, {"p", f.owner}}, "name the requester"},82- {"feedback without a status", 7000, [][]string{{"e", request.ID}, {"p", f.member}}, "payment-required, processing, error, success or partial"},83- {"feedback with an unknown status", 7000, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, "payment-required, processing, error, success or partial"},85+ {"request without a room", event.KIND_JOB_REQUEST, [][]string{{"p", f.member}}, "h tag"},86+ {"request with a bad room", event.KIND_JOB_REQUEST, [][]string{{"h", "Not A Room"}, {"p", f.member}}, "h tag"},87+ {"request without an asked key", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}}, "at least one asked key"},88+ {"request with a bad asked key", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}, {"p", "abc"}}, "hex public keys"},89+ {"request with a bad root", event.KIND_JOB_REQUEST, [][]string{{"h", jobRoom}, {"p", f.member}, {"e", "abc", "", "root"}}, "thread root"},90+ {"accepted without a request", event.KIND_JOB_ACCEPTED, [][]string{{"h", jobRoom}, {"p", f.member}}, "e tag"},91+ {"progress without a requester", event.KIND_JOB_PROGRESS, [][]string{{"h", jobRoom}, {"e", request.ID}}, "p tag"},92+ {"result without a room", event.KIND_JOB_RESULT, [][]string{{"e", request.ID}, {"p", f.member}}, "h tag"},93+ {"error with a bad room", event.KIND_JOB_ERROR, [][]string{{"h", "Not A Room"}, {"e", request.ID}, {"p", f.member}}, "h tag"},94+ {"cancel without a request", event.KIND_JOB_CANCEL, [][]string{{"h", jobRoom}}, "e tag"},95+ {"cancel without a room", event.KIND_JOB_CANCEL, [][]string{{"e", request.ID}}, "h tag"},96+ {"cancel with a bad assignee", event.KIND_JOB_CANCEL, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", "abc"}}, "p tag"},8497 }8598 for _, tc := range cases {8699 err := f.gate.Write(ctx, signed(t, jobMemberSecret, tc.kind, agentNow, tc.tags, ""), session, agentNow)87100 expectError(t, err, "invalid:", tc.fragment)88101 }89- // Well-formed answers from a member pass, including answers to a request90- // made on another relay.102+ // Well-formed answers from an asked member pass, with artifact103+ // references on a result and a cancel from the requester.91104 for _, e := range []event.Event{92- signed(t, jobMemberSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "500", "lnbc1"}}, "output"),93- signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "processing"}}, ""),94- signed(t, jobMemberSecret, 6005, agentNow, [][]string{{"e", strings.Repeat("a", 64)}, {"p", f.owner}}, "elsewhere"),105+ signed(t, jobMemberSecret, event.KIND_JOB_ACCEPTED, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, ""),106+ signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, "halfway"),107+ signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}, {"e", strings.Repeat("a", 64)}, {"a", "30818:" + f.member + ":notes"}, {"r", "https://example.com/report"}}, "output"),108+ signed(t, jobMemberSecret, event.KIND_JOB_ERROR, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, "no such input"),109+ signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, ""),95110 } {96111 if err := f.gate.Write(ctx, e, session, agentNow); err != nil {97112 t.Fatalf("well-formed kind %d rejected: %v", e.Kind, err)98113 }99114 }100115 // Import applies the same shape rules.101- expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, ""), agentNow), "invalid:", "payment-required")116+ expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}}, ""), agentNow), "invalid:", "p tag")117+}118+119+func TestJobAnswersFollowTheHeldRequest(t *testing.T) {120+ f := newJobFixture(t)121+ ctx := context.Background()122+ session := relay.Session{PubKeys: []string{f.member}}123+ other, _ := event.PublicKey(agentOtherSecret)124+ // The owner is a member who was not asked; the member was.125+ request := f.request(t, agentNow, f.member)126+ answer := func(secret string, kind int, tags ...[]string) event.Event {127+ return signed(t, secret, kind, agentNow, append([][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, tags...), "output")128+ }129+ // No answers to requests the relay does not hold, from members either.130+ elsewhere := signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", strings.Repeat("a", 64)}, {"p", other}}, "elsewhere")131+ expectError(t, f.gate.Write(ctx, elsewhere, session, agentNow), "restricted:", "request this relay holds")132+ expectError(t, f.gate.Import(ctx, elsewhere, agentNow), "restricted:", "request this relay holds")133+ // A note that is not a request cannot be answered.134+ note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")135+ f.save(t, note)136+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", note.ID}, {"p", f.member}}, "x"), session, agentNow), "restricted:", "request this relay holds")137+ // The p tag names the requester and the h tag keeps the room.138+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.owner}}, "x"), session, agentNow), "invalid:", "name the requester")139+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_PROGRESS, agentNow, [][]string{{"h", "other-room"}, {"e", request.ID}, {"p", f.member}}, "x"), session, agentNow), "invalid:", "room")140+ // Only a key the request asked may answer it.141+ expectError(t, f.gate.Write(ctx, answer(agentOwnerSecret, event.KIND_JOB_PROGRESS), relay.Session{PubKeys: []string{f.owner}}, agentNow), "restricted:", "key the request asked")142+ if err := f.gate.Write(ctx, answer(jobMemberSecret, event.KIND_JOB_PROGRESS), session, agentNow); err != nil {143+ t.Fatalf("asked member's progress rejected: %v", err)144+ }145+ // Only the requester may cancel.146+ cancel := func(secret string) event.Event {147+ return signed(t, secret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}}, "")148+ }149+ expectError(t, f.gate.Write(ctx, cancel(agentOwnerSecret), relay.Session{PubKeys: []string{f.owner}}, agentNow), "restricted:", "only the requester")150+ if err := f.gate.Write(ctx, cancel(jobMemberSecret), session, agentNow); err != nil {151+ t.Fatalf("requester's cancel rejected: %v", err)152+ }153+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", "other-room"}, {"e", request.ID}}, ""), session, agentNow), "invalid:", "room")102154 }103155 104156 func TestJobPolicyAndMembership(t *testing.T) {105157 f := newJobFixture(t)106158 ctx := context.Background()107159 other, _ := event.PublicKey(agentOtherSecret)108- request := signed(t, agentOtherSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, "")160+ request := signed(t, agentOtherSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", f.member}}, "hello")109161 // The relay writes openly, but job kinds are for members and agents.110162 expectError(t, f.gate.Write(ctx, request, relay.Session{PubKeys: []string{other}}, agentNow), "restricted:", "members and agents")111163 if err := f.gate.Write(ctx, signed(t, agentOtherSecret, 1, agentNow, nil, "note"), relay.Session{PubKeys: []string{other}}, agentNow); err != nil {112164 t.Fatalf("open relay refused a note: %v", err)113165 }114- f.request(t, 5001, agentNow)115- if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, 5002, agentNow, nil, ""), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {166+ f.request(t, agentNow, f.member)167+ if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", f.member}}, "owner asks"), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {116168 t.Fatalf("owner request rejected: %v", err)117169 }118170 // Switching long tasks off refuses every job kind, for members and on
@@ -120,8 +172,8 @@ func TestJobPolicyAndMembership(t *testing.T) {
120172 p := f.gate.cfg.Policy()121173 p.Features.Jobs = false122174 f.gate.cfg.Policy = func() policy.Policy { return p }123- expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, 5001, agentNow+1, nil, ""), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")124- expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow+1, [][]string{{"e", strings.Repeat("b", 64)}, {"p", f.member}, {"status", "success"}}, ""), agentNow), "restricted:", "switched off")175+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, event.KIND_JOB_REQUEST, agentNow+1, [][]string{{"h", jobRoom}, {"p", f.member}}, "x"), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")176+ expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, event.KIND_JOB_CANCEL, agentNow+1, [][]string{{"h", jobRoom}, {"e", strings.Repeat("b", 64)}}, ""), agentNow), "restricted:", "switched off")125177 if err := f.gate.Write(ctx, signed(t, jobMemberSecret, 1, agentNow+1, nil, "still fine"), relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {126178 t.Fatalf("switching jobs off broke notes: %v", err)127179 }
@@ -131,64 +183,65 @@ func TestJobGrantsRequestServeAndBoth(t *testing.T) {
131183 f := newJobFixture(t)132184 ctx := context.Background()133185 session := relay.Session{PubKeys: []string{f.agent}}134- request := f.request(t, 5001, agentNow)186+ request := f.request(t, agentNow, f.agent)135187 answer := func(kind int, extra ...[]string) event.Event {136- tags := append([][]string{{"e", request.ID}, {"p", f.member}}, extra...)188+ tags := append([][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.member}}, extra...)137189 return signed(t, agentSecret, kind, agentNow, tags, "output")138190 }139-140- // request: 5xxx only.141- f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "request"}))142- if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, ""), session, agentNow); err != nil {143- t.Fatalf("request grant refused a request: %v", err)191+ ask := func(asked string) event.Event {192+ return signed(t, agentSecret, event.KIND_JOB_REQUEST, agentNow, [][]string{{"h", jobRoom}, {"p", asked}}, "please")144193 }145- expectRestricted(t, f.gate.Write(ctx, answer(6001), session, agentNow), "kind 6001")146- expectRestricted(t, f.gate.Write(ctx, answer(7000, []string{"status", "processing"}), session, agentNow), "kind 7000")147194 148- // serve: 6xxx and 7000 in reply to a held request; no requests.149- f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}))150- expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow), "kind 5001")151- if err := f.gate.Write(ctx, answer(6001, []string{"amount", "500"}), session, agentNow); err != nil {152- t.Fatalf("serve grant refused a result: %v", err)195+ // request: 43001 and 43005 only.196+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "request"}))197+ if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {198+ t.Fatalf("request grant refused a request: %v", err)153199 }154- if err := f.gate.Write(ctx, answer(7000, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"}), session, agentNow); err != nil {155- t.Fatalf("serve grant refused feedback: %v", err)200+ own := ask(f.member)201+ f.save(t, own)202+ if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", own.ID}}, ""), session, agentNow); err != nil {203+ t.Fatalf("request grant refused a cancel: %v", err)156204 }157- unknown := signed(t, agentSecret, 6001, agentNow, [][]string{{"e", strings.Repeat("c", 64)}, {"p", f.member}}, "")158- expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")159- expectRestricted(t, f.gate.Import(ctx, unknown, agentNow), "requests this relay holds")160- // A note that is not a request cannot be answered, and an answer must161- // match the request's kind and author.162- note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")163- if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {164- _, err := storage.SaveTx(ctx, tx, note, storage.SaveOptions{Now: agentNow})165- return err166- }); err != nil {167- t.Fatal(err)205+ expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow), "kind 43004")206+ expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_PROGRESS), session, agentNow), "kind 43003")207+208+ // serve: answers to a held request that asked the agent; no requests209+ // or cancels.210+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "serve"}))211+ expectRestricted(t, f.gate.Write(ctx, ask(f.member), session, agentNow), "kind 43001")212+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_CANCEL, agentNow, [][]string{{"h", jobRoom}, {"e", own.ID}}, ""), session, agentNow), "kind 43005")213+ for _, kind := range []int{event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS, event.KIND_JOB_RESULT, event.KIND_JOB_ERROR} {214+ if err := f.gate.Write(ctx, answer(kind), session, agentNow); err != nil {215+ t.Fatalf("serve grant refused kind %d: %v", kind, err)216+ }168217 }169- expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", note.ID}, {"p", f.member}}, ""), session, agentNow), "requests this relay holds")170- expectError(t, f.gate.Write(ctx, answer(6002), session, agentNow), "invalid:", "does not answer")171- expectError(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.owner}}, ""), session, agentNow), "invalid:", "name the requester")218+ unknown := signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", strings.Repeat("c", 64)}, {"p", f.member}}, "x")219+ expectError(t, f.gate.Write(ctx, unknown, session, agentNow), "restricted:", "request this relay holds")220+ expectError(t, f.gate.Import(ctx, unknown, agentNow), "restricted:", "request this relay holds")221+ // A request that asked someone else cannot be answered by the agent.222+ notAsked := f.request(t, agentNow+1, f.owner)223+ expectError(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", notAsked.ID}, {"p", f.member}}, "x"), session, agentNow), "restricted:", "key the request asked")224+ expectError(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_JOB_RESULT, agentNow, [][]string{{"h", jobRoom}, {"e", request.ID}, {"p", f.owner}}, "x"), session, agentNow), "invalid:", "name the requester")172225 173226 // both: requests and answers.174- f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"jobs", "both"}))175- if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {227+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"jobs", "both"}))228+ if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {176229 t.Fatalf("both grant refused a request: %v", err)177230 }178- if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {231+ if err := f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow); err != nil {179232 t.Fatalf("both grant refused a result: %v", err)180233 }181234 // A k tag still covers a single job kind, and answers from a k grant182235 // name a held request as well.183- f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"k", "5001"}, []string{"k", "6001"}))184- if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {236+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"room", jobRoom}, []string{"k", "43001"}, []string{"k", "43004"}))237+ if err := f.gate.Write(ctx, ask(f.member), session, agentNow); err != nil {185238 t.Fatalf("k grant refused a request: %v", err)186239 }187- expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5002, agentNow, nil, ""), session, agentNow), "kind 5002")188- if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {240+ expectRestricted(t, f.gate.Write(ctx, answer(event.KIND_JOB_PROGRESS), session, agentNow), "kind 43003")241+ if err := f.gate.Write(ctx, answer(event.KIND_JOB_RESULT), session, agentNow); err != nil {189242 t.Fatalf("k grant refused a result: %v", err)190243 }191- expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")244+ expectError(t, f.gate.Write(ctx, unknown, session, agentNow), "restricted:", "request this relay holds")192245 // The jobs tag is bounded.193246 if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "all"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), "request, serve or both") {194247 t.Fatalf("jobs tag accepted an unknown value: %v", err)
M internal/policy/policy.go +3 -2
M scripts/qa/buzz-chat-fixture.mjs +2 -2
@@ -23,8 +23,8 @@ if(process.argv[1]?.endsWith('buzz-chat-fixture.mjs')) {
2323 const root=await publish('alice',9,'Could you prepare a preview?',[['h','work']]);2424 const reply=await publish('owner',9,'Keep the existing color palette.',[['h','work'],['e',root.id,'','reply',pub.alice]]);2525 await publish('alice',9,'And check the layout on mobile.',[['h','work'],['e',reply.id,'','reply',pub.owner]]);26- const task=await publish('owner',5000,'Prepare the site preview',[['h','work'],['e',root.id],['p',pub.agent],['subject','Prepare the site preview']]);27- await publish('agent',7000,'Desktop checks passed. Checking mobile navigation next.',[['h','work'],['e',task.id],['p',pub.owner],['status','processing','2 of 3 checks complete']]);26+ const task=await publish('owner',43001,'Prepare the site preview',[['h','work'],['e',root.id,'','root'],['p',pub.agent],['subject','Prepare the site preview']]);27+ await publish('agent',43003,'Desktop checks passed. Checking mobile navigation next.',[['h','work'],['e',task.id],['p',pub.owner]]);2828 const approval=await publish('agent',9,'Publish the preview with the existing palette and updated mobile navigation?',[['h','work'],['e',root.id,'','root'],['request','approve'],['p',pub.owner],['subject','Publish preview']]);2929 const question=await publish('agent',9,'Which title should the preview use?',[['h','work'],['request','question'],['p',pub.owner],['subject','Preview title']]);3030 await publish('owner',9,'The task card holds progress updates. Replies stay in their thread.',[['h','work']]);
M scripts/qa/buzz-runner-integration.mjs +10 -10
@@ -26,7 +26,7 @@ try {
2626 relay = spawn(binary, ['serve', '--data-dir', data, '--default-tenant', 'main', '--listen', `127.0.0.1:${port}`, '--allow-private-relays'], {stdio: ['ignore', 'ignore', 'pipe']});2727 await waitFor(`${base}/readyz`);2828 await publish('owner', 0, JSON.stringify({name: 'QA owner'}));29- await publish('owner', 30392, '', [['d', pub.agent], ['p', pub.agent], ['name', 'QA agent'], ['room', 'work'], ['room', 'cancel'], ['room', 'crash'], ['k', '0'], ['k', '9'], ['k', '5000'], ['k', '6000'], ['k', '7000'], ['k', '7'], ['k', '1111'], ['k', '20001'], ['jobs', 'both'], ['expiration', String(Math.floor(Date.now() / 1000) + 3600)]]);29+ await publish('owner', 30392, '', [['d', pub.agent], ['p', pub.agent], ['name', 'QA agent'], ['room', 'work'], ['room', 'cancel'], ['room', 'crash'], ['k', '0'], ['k', '9'], ['jobs', 'both'], ['k', '7'], ['k', '1111'], ['k', '20001'], ['jobs', 'both'], ['expiration', String(Math.floor(Date.now() / 1000) + 3600)]]);3030 await publish('owner', 9007, '', [['h', 'work'], ['name', 'QA room'], ['visibility', 'open']]);3131 await publish('owner', 9000, '', [['h', 'work'], ['p', pub.agent, 'member']]);3232 for (const room of ['cancel', 'crash']) {
@@ -36,35 +36,35 @@ try {
3636 const acp = resolve(root, 'scripts/qa/buzz-runner-acp.mjs');3737 runner = startRunner(acp, `${data}/queue.json`);3838 const first = await publish('owner', 9, 'Please publish the preview.', [['h', 'work'], ['p', pub.agent]]);39- const request = await waitForEvent({kinds: [5000], '#h': ['work'], '#p': [pub.agent]});40- await waitForEvent({kinds:[7000], '#h':['work'], '#e':[request.id]}, event=>event.tags.some(tag=>tag[0]==='status'&&tag[1]==='processing'));39+ const request = await waitForEvent({kinds: [43001], '#h': ['work'], '#p': [pub.agent]});40+ await waitForEvent({kinds: [43003], '#h': ['work'], '#e': [request.id]});4141 const approval = await waitForEvent({kinds: [9], '#h': ['work'], '#p': [pub.owner]}, event => event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));4242 assert(approval.tags.some(tag=>tag[0]==='expiration'&&Number(tag[1])>Math.floor(Date.now()/1000)), 'approval has no bounded expiry');4343 await publish('owner', 7, '+', [['e', approval.id], ['p', pub.agent], ['k', '9'], ['h', 'work']]);44- const result = await waitForEvent({kinds: [6000], '#h': ['work'], '#e': [request.id]});44+ const result = await waitForEvent({kinds: [43004], '#h': ['work'], '#e': [request.id]});4545 const reply = await waitForEvent({kinds: [9], '#h': ['work'], '#e': [first.id], '#p': [pub.owner]}, event => event.pubkey === pub.agent && event.content === 'Preview published.');4646 assert(result.content === 'Preview published.', `unexpected result: ${result.content}`);4747 assert(reply.content === 'Preview published.', `unexpected reply: ${reply.content}`);4848 await stopChild(runner);4949 runner = startRunner(acp, `${data}/cancel-queue.json`, {FAKE_ACP_MODE: 'wait'}, 'cancel');5050 const cancelMention = await publish('owner', 9, 'Start a task I will cancel.', [['h', 'cancel'], ['p', pub.agent]]);51- const cancelRequest = await waitForEvent({kinds: [5000], '#h': ['cancel'], '#p': [pub.agent]}, event => event.content.includes('Start a task'));51+ const cancelRequest = await waitForEvent({kinds: [43001], '#h': ['cancel'], '#p': [pub.agent]}, event => event.content.includes('Start a task'));5252 const cancelApproval = await waitForEvent({kinds: [9], '#h': ['cancel'], '#p': [pub.owner]}, event => event.created_at >= cancelRequest.created_at && event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));5353 await publish('owner', 9, '/cancel', [['h', 'cancel'], ['p', pub.agent]]);54- const canceled = await waitForEvent({kinds: [7000], '#h': ['cancel'], '#e': [cancelRequest.id]}, event => event.tags.some(tag => tag[0] === 'status' && tag[1] === 'error'));54+ const canceled = await waitForEvent({kinds: [43006], '#h': ['cancel'], '#e': [cancelRequest.id]});5555 assert(canceled.content === 'Canceled', `unexpected cancellation: ${canceled.content}`);5656 await stopChild(runner);5757 const marker = `${data}/crash-once.marker`;5858 runner = startRunner(acp, `${data}/crash-queue.json`, {FAKE_ACP_MODE: 'crash-once', FAKE_ACP_MARKER: marker}, 'crash');5959 const crashedMention = await publish('owner', 9, 'Crash once.', [['h', 'crash'], ['p', pub.agent]]);60- const crashedRequest = await waitForEvent({kinds: [5000], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Crash once'));61- const crashed = await waitForEvent({kinds: [7000], '#h': ['crash'], '#e': [crashedRequest.id]}, event => event.tags.some(tag => tag[0] === 'status' && tag[1] === 'error'));60+ const crashedRequest = await waitForEvent({kinds: [43001], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Crash once'));61+ const crashed = await waitForEvent({kinds: [43006], '#h': ['crash'], '#e': [crashedRequest.id]});6262 assert(crashed.content.includes('Agent stopped'), `missing crash report: ${crashed.content}`);6363 const retryMention = await publish('owner', 9, 'Recover after crash.', [['h', 'crash'], ['p', pub.agent]]);64- const retryRequest = await waitForEvent({kinds: [5000], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Recover after crash'));64+ const retryRequest = await waitForEvent({kinds: [43001], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Recover after crash'));6565 const retryApproval = await waitForEvent({kinds: [9], '#h': ['crash'], '#p': [pub.owner]}, event => event.created_at >= retryRequest.created_at && event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));6666 await publish('owner', 7, '+', [['e', retryApproval.id], ['p', pub.agent], ['k', '9'], ['h', 'crash']]);67- const retryResult = await waitForEvent({kinds: [6000], '#h': ['crash'], '#e': [retryRequest.id]});67+ const retryResult = await waitForEvent({kinds: [43004], '#h': ['crash'], '#e': [retryRequest.id]});6868 assert(retryResult.content === 'Preview published.', `unexpected recovery result: ${retryResult.content}`);6969 console.log(JSON.stringify({ok: true, request: request.id, approval: approval.id, result: result.id, reply: reply.id, canceled: cancelMention.id, crash: crashedMention.id, recovered: retryMention.id}));7070 } finally {
M scripts/webmcp.test.mjs +5 -5
@@ -192,18 +192,18 @@ test("job tools list and read long tasks through the session", async () => {
192192 const requests = [];193193 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {194194 requests.push(new URL(url, "https://tiny.example"));195- return new Response(JSON.stringify({items: [{id: "a".repeat(64), kind: 5001, state: "open", status: "processing"}], next_cursor: "",196- item: {id: "b".repeat(64), kind: 5002, state: "done"}, feedback: [{status: "processing"}], results: [{kind: 6002}]}));195+ return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open", status: "running"}], next_cursor: "",196+ item: {id: "b".repeat(64), state: "done", status: "done"}, answers: [{kind: 43003}, {kind: 43004}]}));197197 }});198198 const list = tools.get("tiny.list_jobs");199199 assert.equal(list.annotations.readOnlyHint, true);200- assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,all");200+ assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,failed,cancelled,all");201201 assert.equal(list.inputSchema.properties.mine.type, "boolean");202202 const listed = await list.execute({state: "open", mine: true, limit: 5});203203 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");204204 assert.equal(requests.at(-1).searchParams.get("method"), "browsejobs");205205 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", mine: true, limit: 5}]);206- assert.equal(listed.structuredContent.result.items[0].status, "processing");206+ assert.equal(listed.structuredContent.result.items[0].status, "running");207207 const read = tools.get("tiny.read_job");208208 assert.equal(read.annotations.readOnlyHint, true);209209 assert.deepEqual([...read.inputSchema.required], ["id"]);
@@ -211,7 +211,7 @@ test("job tools list and read long tasks through the session", async () => {
211211 assert.equal(requests.at(-1).searchParams.get("method"), "browsejob");212212 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);213213 assert.equal(detail.structuredContent.result.item.state, "done");214- assert.equal(detail.structuredContent.result.results[0].kind, 6002);214+ assert.equal(detail.structuredContent.result.answers[1].kind, 43004);215215 });216216 217217 test("link, files and notification tools reach the new surfaces", async () => {
M tinyclient/chat-activity.go +45 -27
@@ -19,15 +19,20 @@ type chatActivityItem struct {
1919 Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string2020 CreatedAt, Expires int642121 Answer string22- CanDecide, Question, Encrypted bool22+ CanDecide, Question bool2323 Native, Multiple bool2424 Freeform bool2525 Interaction string2626 Options []chatActivityOption27- Result *chatActivityResult27+ Progress string28+ Result, Error *chatActivityResult2829 }2930 type chatActivityOption struct{ ID, Label string }30-type chatActivityResult struct{ ID, Content string }31+type chatActivityResult struct {32+ ID, Content string33+ Artifacts []chatActivityLink34+}35+type chatActivityLink struct{ Href, Label string }3136 type chatActivityPage struct {3237 Items []chatActivityItem3338 Endpoint, Room, Actor, Error string
@@ -41,41 +46,31 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
4146 if !eventIDPattern.MatchString(id) {4247 continue4348 }44- ev := valueMap(item["event"])45- title := firstString(tagValues(ev, "subject"))49+ title := strings.TrimSpace(plainString(item["subject"]))4650 if title == "" {4751 title = strings.TrimSpace(plainString(item["content"]))4852 }49- if title == "" {50- for _, raw := range collaborationSlice(item, "inputs") {51- input := valueMap(raw)52- if plainString(input["type"]) == "text" {53- title = plainString(input["data"])54- break55- }56- }57- }5853 if title == "" {5954 title = "Agent task"6055 }61- title = truncateActivity(title, 100)62- row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: plainString(item["kind"]), State: plainString(item["state"]), Status: plainString(item["status"]), Title: title, Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"])}63- row.Encrypted, _ = item["encrypted"].(bool)64- if row.Encrypted {65- row.Title = "Encrypted task"66- row.Content = "Open the task in its originating client."67- }68- if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {69- row.Author = plainString(feedback["provider"])70- row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))56+ row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: "43001", State: plainString(item["state"]), Status: plainString(item["status"]), Title: truncateActivity(title, 100), Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: unixSeconds(item["expires"])}57+ if progress := valueMap(item["progress"]); len(progress) > 0 {58+ row.Progress = strings.TrimSpace(plainString(progress["content"]))7159 }7260 if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {7361 row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}74- row.Status = "success"75- if row.Encrypted {76- row.Result.Content = ""62+ for _, raw := range collaborationSlice(result, "artifacts") {63+ if link, ok := jobArtifactLink(valueMap(raw)); ok {64+ row.Result.Artifacts = append(row.Result.Artifacts, link)65+ }7766 }7867 }68+ if failure := valueMap(item["error"]); eventIDPattern.MatchString(plainString(failure["id"])) {69+ row.Error = &chatActivityResult{ID: plainString(failure["id"]), Content: plainString(failure["content"])}70+ }71+ if row.State == "" {72+ row.State = "open"73+ }7974 if row.Status == "" {8075 row.Status = "queued"8176 }
@@ -167,6 +162,29 @@ func nativeActivityAnswer(content string, options []chatActivityOption) string {
167162 return result168163 }169164 165+// jobArtifactLink turns one artifact reference of a result into a link:166+// an event id opens the event, a coordinate opens the addressable event167+// and an http or https URL opens as given.168+func jobArtifactLink(artifact map[string]any) (chatActivityLink, bool) {169+ value := strings.TrimSpace(plainString(artifact["value"]))170+ switch plainString(artifact["type"]) {171+ case "e":172+ if eventIDPattern.MatchString(value) {173+ return chatActivityLink{Href: "/e/" + value, Label: "Event " + value[:8]}, true174+ }175+ case "a":176+ parts := strings.SplitN(value, ":", 3)177+ if len(parts) == 3 && eventIDPattern.MatchString(parts[1]) && parts[2] != "" {178+ return chatActivityLink{Href: "/a/" + parts[0] + ":" + parts[1] + ":" + url.PathEscape(parts[2]), Label: parts[2]}, true179+ }180+ case "r":181+ if strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://") {182+ return chatActivityLink{Href: value, Label: value}, true183+ }184+ }185+ return chatActivityLink{}, false186+}187+170188 func eventHasTag(item map[string]any, name string) bool {171189 for _, tag := range tagValues(item["event"], name) {172190 if tag == "1" {
M tinyclient/chat-activity.html +1 -1
@@ -4,5 +4,5 @@
44 {{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}55 <p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals/{{.ID}}">Open request</a></p>66 {{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit" data-primary>Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Reply</button>{{else}}<button type="submit" name="reaction" value="+" data-primary>Approve</button><button type="submit" name="reaction" value="-" data-danger>Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}7-{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}7+{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>from {{template "nostrName" .Author}}</small></p>{{if ne .Title .Content}}{{with .Content}}<div>{{chatMarkdown .}}</div>{{end}}{{end}}{{with .Progress}}<p>{{.}}</p>{{end}}{{with .Result}}<h4>Result</h4>{{if .Content}}<div>{{chatMarkdown .Content}}</div>{{end}}{{if .Artifacts}}<ul>{{range .Artifacts}}<li><a href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}{{with .Error}}<h4>Error</h4><p>{{.Content}}</p>{{end}}{{if eq .State "cancelled"}}<p>Cancelled by the requester.</p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}88 </article>{{end}}{{end}}{{end}}
M tinyclient/chat_activity_http_test.go +3 -2
@@ -22,7 +22,8 @@ func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, r
2222 if b.denied {2323 return nil, errors.New("private details that must stay hidden")2424 }25- return map[string]any{"approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil25+ job := map[string]any{"id": roomThread, "requester": roomOwner, "assignees": []any{roomAgent}, "subject": "Build the preview", "content": "Build it with the dark palette.", "state": "done", "status": "done", "created_at": 1, "result": map[string]any{"id": roomReply, "provider": roomAgent, "content": "Preview is up.", "artifacts": []any{map[string]any{"type": "r", "value": "https://preview.example/site"}, map[string]any{"type": "e", "value": roomReply}}}}26+ return map[string]any{"jobs": []any{job}, "approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil2627 }2728 2829 func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
@@ -40,7 +41,7 @@ func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
4041 if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {4142 t.Fatalf("request context: %d %v %#v body=%s", res.Code, res.Header(), b, res.Body.String())4243 }43- for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`} {44+ for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`, `data-activity="job"`, `data-state="done"`, `<strong>Build the preview</strong>`, `data-status="done"`, `<h4>Result</h4>`, `href="https://preview.example/site"`, `href="/r/team/e/` + roomReply + `">Event `, `href="/r/team/e/` + roomThread + `">Open task</a>`} {4445 if !strings.Contains(body, want) {4546 t.Errorf("missing %q in %s", want, body)4647 }
M tinyclient/chat_activity_test.go +33 -2
@@ -8,7 +8,7 @@ import (
88 func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {99 actor := strings.Repeat("a", 64)1010 other := strings.Repeat("b", 64)11- jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "status": "working", "content": "Indexing files"}}}11+ jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "state": "open", "status": "running", "content": "Indexing files", "progress": map[string]any{"id": strings.Repeat("9", 64), "provider": actor, "content": "Halfway through"}}}}1212 approvals := map[string]any{"items": []any{1313 map[string]any{"id": strings.Repeat("2", 64), "asker": other, "asked": []any{actor}, "subject": "Publish result", "state": "open", "kind": "4550"},1414 map[string]any{"id": strings.Repeat("3", 64), "asker": other, "asked": []any{other}, "subject": "Private request", "state": "open", "kind": "4550"},
@@ -17,7 +17,7 @@ func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {
1717 if len(items) != 2 {1818 t.Fatalf("got %d activity items, want job and addressed approval", len(items))1919 }20- if items[0].Type != "job" || items[0].Status != "working" || items[0].Title != "Indexing files" {20+ if items[0].Type != "job" || items[0].Status != "running" || items[0].Title != "Indexing files" || items[0].Author != other || items[0].Progress != "Halfway through" {2121 t.Fatalf("unexpected job card: %#v", items[0])2222 }2323 if items[1].Type != "approval" || !items[1].CanDecide || items[1].ID != strings.Repeat("2", 64) {
@@ -94,3 +94,34 @@ func TestChatActivityShowsChoiceLabelsAndExplanation(t *testing.T) {
9494 })9595 }9696 }97+98+func TestChatActivityJobCardsCarryResultsArtifactsAndErrors(t *testing.T) {99+ actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)100+ done := map[string]any{"id": strings.Repeat("1", 64), "requester": actor, "assignees": []any{other}, "subject": "Build the preview", "content": "Build it with the dark palette.", "state": "done", "status": "done", "result": map[string]any{"id": strings.Repeat("2", 64), "provider": other, "content": "Preview is up.", "artifacts": []any{101+ map[string]any{"type": "e", "value": strings.Repeat("3", 64)},102+ map[string]any{"type": "a", "value": "30818:" + other + ":release notes"},103+ map[string]any{"type": "r", "value": "https://preview.example/site"},104+ map[string]any{"type": "r", "value": "javascript:alert(1)"},105+ map[string]any{"type": "e", "value": "short"},106+ }}}107+ failed := map[string]any{"id": strings.Repeat("4", 64), "requester": actor, "content": "Transcribe the call.", "state": "failed", "status": "failed", "error": map[string]any{"id": strings.Repeat("5", 64), "provider": other, "content": "no audio track"}}108+ queued := map[string]any{"id": strings.Repeat("6", 64), "requester": actor, "subject": "Later"}109+ items := chatActivityView(map[string]any{"items": []any{done, failed, queued}}, nil, actor, "/chat/activity", "main")110+ if len(items) != 3 {111+ t.Fatalf("got %d cards", len(items))112+ }113+ card := items[0]114+ if card.Title != "Build the preview" || card.State != "done" || card.Result == nil || card.Result.Content != "Preview is up." || len(card.Result.Artifacts) != 3 {115+ t.Fatalf("done card = %#v", card)116+ }117+ links := card.Result.Artifacts118+ if links[0].Href != "/e/"+strings.Repeat("3", 64) || links[1].Href != "/a/30818:"+other+":release%20notes" || links[1].Label != "release notes" || links[2].Href != "https://preview.example/site" {119+ t.Fatalf("artifact links = %#v", links)120+ }121+ if card := items[1]; card.State != "failed" || card.Error == nil || card.Error.Content != "no audio track" || card.Result != nil {122+ t.Fatalf("failed card = %#v", card)123+ }124+ if card := items[2]; card.State != "open" || card.Status != "queued" || card.Title != "Later" {125+ t.Fatalf("queued card = %#v", card)126+ }127+}
M tinyclient/remote_rooms_test.go +1 -1
M tinyclient/webmcp.js +3 -3
@@ -110,10 +110,10 @@
110110 object({pubkey}), reads, (input, signal) => query("browseprofile", input, signal));111111 register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.",112112 object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal));113- 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.",114- object({cursor: text, limit, state: {type: "string", enum: ["open", "done", "all"]}, mine: {type: "boolean"}}), reads,113+ register("tiny.list_jobs", "List long task requests (kind 43001) visible to your account, each with its room, requester, assignees, subject, state (open, done, failed or cancelled), status (queued, accepted, running, done, failed or cancelled), newest progress, result with its artifacts, error and cancel time. state narrows the list; mine lists only your own requests.",114+ object({cursor: text, limit, state: {type: "string", enum: ["open", "done", "failed", "cancelled", "all"]}, mine: {type: "boolean"}}), reads,115115 (input, signal) => query("browsejobs", input, signal));116- register("tiny.read_job", "Read one long task request by event id with its feedback timeline and results.",116+ register("tiny.read_job", "Read one long task request by event id with its settled state and its answers oldest first: accepted, progress, result, cancel and error events.",117117 object({id: hash}, ["id"]), reads, (input, signal) => query("browsejob", input, signal));118118 const pageName = {type: "string", minLength: 1, maxLength: 512, description: "Page name or title; the relay normalizes it to the NIP-54 d tag."};119119 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.",