diff --git a/README.md b/README.md index a4c1af4..8c9691b 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ Find related context: ```bash tap node search "billing migration" --keg @acme/engineering +tap node grep 'BILL-[0-9]+' --keg @acme/engineering tap tag list "release and payments" --keg @acme/engineering tap node backlinks 42 --keg @acme/engineering ``` diff --git a/docs/README.md b/docs/README.md index 295d979..4130350 100644 --- a/docs/README.md +++ b/docs/README.md @@ -107,7 +107,16 @@ writing KEG files directly. - `tap node read NODE_ID` - display node content and metadata. - `tap node edit NODE_ID` - edit a node. - `tap node list` - list indexed nodes. -- `tap node search QUERY` - search node content. +- `tap node search QUERY...` - find the nodes most relevant to a query, ranked + by the hub across every keg you can read, one keg (`--keg`), or listed kegs + (`--kegs @ns/a,@ns/b`, at most 50). The query takes plain terms, `"quoted phrases"`, and + `-exclusions`. Each hit prints reference, score, title, and a highlighted + snippet; `--limit` caps a page (the hub chooses by default), the next page's + cursor is printed to stderr for `--cursor`, `--id-only` prints references, + and `--json` prints the hub's response. MCP's `node_search` takes the same + parameters; under a flight it never searches outside the cover. +- `tap node grep PATTERN` - match node content lines in one keg against a + regex (MCP `node_grep`), for exact identifiers and audits. - `tap tag list [EXPR]` - list tags or query tagged nodes. - `tap node backlinks NODE_ID` - show nodes that link to a node. - `tap node links NODE_ID` - show outgoing links from a node. @@ -116,7 +125,14 @@ writing KEG files directly. - `tap bootstrap` - create or refresh user-level setup. - `tap use [@namespace/keg]` - set or inspect project/user keg selection. -- `tap keg create @namespace/name` - create a keg. +- `tap keg create @namespace/name` - create a keg. `--kegv 2026-09` creates a + KEGv2 preview keg where the hub enables it; the default, `2025-07`, is a + Markdown KEGv1. A hub with the preview off refuses `--kegv 2026-09` with + `KEG_VERSION_UNSUPPORTED`; a hub that predates versioned kegs creates a + KEGv1 keg, and `tap` reports that the keg exists as KEGv1 rather than + succeeding. A keg's version never changes after creation. Keg listings, + `tap keg info`, and orientation label KEGv2 kegs, and an operation a keg's + version does not offer fails with `KEG_VERSION_UNSUPPORTED`. - `tap keg list` - list kegs visible on a hub. - `tap keg grant|grants|revoke` - manage per-keg access. - `tap keg visibility public|private` - set keg visibility. diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index ad6785c..9c56f36 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -97,7 +97,8 @@ explicitly configured root failed to initialize. | --- | --- | | `node_read` | Read content of one or more nodes | | `node_list` | List nodes with optional query filtering | -| `node_search` | Full-text search across node content | +| `node_search` | Rank nodes by relevance to a query across every readable KEG, one `keg`, or listed `kegs`; pages with `next_cursor` | +| `node_grep` | Match node content lines against a regex in one KEG | | `tag_list` | List tags or find nodes by query | | `node_backlinks` | Find nodes linking to a node | | `node_links` | List outgoing links from a node | diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md index fefbf85..e6b23e4 100644 --- a/docs/api-compatibility.md +++ b/docs/api-compatibility.md @@ -62,6 +62,25 @@ curl -H 'Tapper-API-Version: 2026-09-28' \ https://hub.example.com/api/v1/whoami ``` +## Ranked search route + +Ranked search is an optional Hub capability, so a client must tell a Hub +without it from the route's own failures. Every response from +`POST /api/v1/tap/search`, success and error alike, carries +`Tapper-Search: ranked` (`tapapi.SearchRouteHeader` and +`tapapi.SearchRouteHeaderValue`). Tapper reads a 404 or 405 without that +header as a Hub that does not offer ranked search, reported as +`NOT_SUPPORTED` with a pointer to `node_grep`; with the header, the error envelope +maps as usual, so a `NOT_FOUND` names a KEG the caller cannot read. + +A search request names at most 50 KEGs (`tapapi.SearchMaxKegs`). A governed +request, one carrying the `Tapper-Orientation` proof, may leave `kegs` empty: +the Hub then searches every readable KEG in the proof's flight cover. Tapper +relies on that for an unscoped `node_search` inside a flight, so a cover wider +than 50 KEGs still searches in one request. Without the proof, Tapper names +the flight's cover itself and refuses a cover wider than 50 KEGs with +`INVALID`, asking for `kegs` to narrow it. + Publish the Tapper commit first; a hub that embeds Tapper then pins that published SHA on its own side, by its own procedure. This development change does not authorize tags, releases, merges, or deployment. diff --git a/docs/configuration/keg-settings.md b/docs/configuration/keg-settings.md index 0d4b445..18adfeb 100644 --- a/docs/configuration/keg-settings.md +++ b/docs/configuration/keg-settings.md @@ -44,9 +44,15 @@ they are not included in aggregate orientation. and a nonzero node create or edit resolves to validation mode `block`, that write must explicitly select one schema. The selection becomes the completed node's `meta.type`, and the completed content and metadata are validated -against it. An existing stored type does not satisfy the explicit-selection -requirement. Use `--schema TYPE` with `tap node create`, `tap node edit`, or metadata -writes. +against it. A `type` declared in submitted metadata does not satisfy the +explicit-selection requirement. Use `--schema TYPE` with `tap node create`, +`tap node edit`, or metadata writes. + +An edit that replaces only content leaves metadata alone, so it keeps the +node's stored type instead: it needs no selection and is validated against that +type. This does not cover an untyped node, which has no type to keep, or +content whose frontmatter declares a different type. Metadata-only edits and +edits of both parts still need a selection. `human`, `agent`, and `api` each accept `off`, `warn`, or `block`, and continue to control validation even when strict is enabled. The defaults remain diff --git a/docs/configuration/schemas.md b/docs/configuration/schemas.md index c002e1d..cee61e7 100644 --- a/docs/configuration/schemas.md +++ b/docs/configuration/schemas.md @@ -26,7 +26,9 @@ Select a schema for a node write with `tap node create --schema TYPE`, `tap node `type` in attributes, Markdown frontmatter, or metadata is rejected instead of silently taking precedence. Under `schemaPolicy.strict`, selection is required only when the resolved actor validation mode is `block`; it remains optional -for `warn`, `off`, non-strict kegs, and node 0. +for `warn`, `off`, non-strict kegs, and node 0. A content-only edit of a typed +node keeps its stored type and needs no selection; see +[KEG settings](keg-settings.md). Metadata property rows can define note maturity scoring under the property's `maturity` key. The property name is the scored metadata attribute, so each row diff --git a/docs/output-formats.md b/docs/output-formats.md index 59415f9..6981c3a 100644 --- a/docs/output-formats.md +++ b/docs/output-formats.md @@ -1,6 +1,6 @@ # Output Formats -`tap node list`, `tap node search`, `tap tag list`, `tap node links`, and `tap node backlinks` all render +`tap node list`, `tap node grep`, `tap tag list`, `tap node links`, and `tap node backlinks` all render node listings through one `--format` template, and the MCP tools of the same names accept the same string. @@ -149,6 +149,25 @@ same fields (`entity=plan`, `.hash=`) are evaluated by the Hub as well. - **Values are never re-scanned.** A node whose title contains `%c` renders it literally. +## Ranked search output + +`tap node search` and `node_search` rank hits across KEGs, so their rows are +not node listings and take no `--format` template. Each hit is one +tab-separated line: + +```text +keg:@acme/notes/12 4.250 Festival trip **festival** in Los Angeles +``` + +The columns are the node reference, the score to three decimals, the title, +and the snippet with matches wrapped in `**` (bold on a terminal). Control +characters collapse to spaces. `--id-only` prints only references, and +`--json` prints the hub's response unchanged, including matched `fields`, +`sources`, highlight offsets, `terms`, and `mode`. Warnings and the next page's +cursor go to stderr, so stdout stays one hit per line. `node_search` text ends +with the same `next cursor: ...` line when another page remains, so text-only +clients can page too. + ## See also - [Query Expressions](query-expressions.md) — the same vocabulary, in predicate diff --git a/integrations/content/authoring.md b/integrations/content/authoring.md index 11fd5e5..f010f15 100644 --- a/integrations/content/authoring.md +++ b/integrations/content/authoring.md @@ -6,8 +6,13 @@ schema_read returns a schema's YAML data and hash. Follow its requirements. node_create accepts nodes (1-100), each with a unique key and markdown content beginning with an H1. meta is a YAML string, not a JSON object. Metadata must not be embedded as content frontmatter. schema is a sibling of meta; it sets -meta.type and a conflicting type is rejected. Schema selection is required only -when strict policy and agent mode both block. +meta.type and a conflicting type is rejected. On a strict keg (the default for +new kegs, while agent validation blocks) schema is required: +- node_create: always; type in meta alone does not count. +- node_edit with meta only: required. +- node_edit with content only: not needed; the node keeps its current type and + the content is validated against it. +- node_edit with both: required, and both content and meta must satisfy it. Example node_create arguments: ```json diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 92f35d8..855d6d8 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -37,7 +37,7 @@ it. | --- | --- | | `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | | `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | | `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | @@ -55,7 +55,8 @@ accessible transitive descendants. | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp__tapper__node_search` | Regex search over node content. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | +| `mcp__tapper__node_search` | Find the nodes most relevant to a query, ranked by the Hub across every KEG the call may read, one `keg`, or the listed `kegs`. Takes plain terms, `"quoted phrases"`, and `-exclusions`. Hits carry `ref`, `score`, `title`, matched `fields`, and a `snippet` with highlights; follow `next_cursor`. | +| `mcp__tapper__node_grep` | Regex match over node content lines in one KEG, in node id order, for exact identifiers, symbols, and audits. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | | `mcp__tapper__tag_list` | List tags or filter nodes by a boolean expression over tags, attributes, and dot-prefix stats fields (for example `tapper and .created>2026-01-01`). | | `mcp__tapper__node_list` | List nodes in a keg with optional filters. | | `mcp__tapper__node_read` | Read one or more nodes. Each structured row pairs `node_id` and `hash` with that node's `content` and `meta`, so a read feeds straight into `node_edit`. Supports `meta_only`, `content_only`, `stats_only`, and `query` expression selection as an alternative to explicit node IDs. | @@ -65,8 +66,10 @@ accessible transitive descendants. | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | | `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | -Pass `id_only: true` to `node_search` and `tag_list` when you only need IDs for follow-up -reads — it keeps token consumption bounded on large result sets. +Start topic lookups with `node_search`; reach for `node_grep` when you need +every line matching an exact pattern. Pass `id_only: true` to `node_grep` and +`tag_list` when you only need IDs for follow-up reads — it keeps token +consumption bounded on large result sets. ## Query expressions @@ -146,9 +149,14 @@ from `node_read`, and either document may be omitted to leave it untouched: ```json {"nodes": [{"node_id": "12", "content": "# Revised\n\nBody", "expected_content_hash": "CONTENT_HASH_FROM_READ"}]} -{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ"}]} +{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ", "schema": "document"}]} ``` +On a strict keg (the default for new kegs) every `node_create` item and every +`node_edit` item that supplies `meta` needs `schema`; `type` in `meta` alone does +not count. A content-only edit keeps the node's current type and is validated +against it, so it needs no `schema`. + `node_delete` carries only ids and the combined hashes: ```json @@ -199,14 +207,22 @@ Text content is JSON rendered from the same public object as structuredContent. The message field preserves prose, diagnostics, and legacy formatted output. Images remain in MCP image content blocks alongside JSON metadata. -For node_list, node_search, tag_list, node_links, and node_backlinks, follow +For node_list, node_grep, tag_list, node_links, and node_backlinks, follow next_offset until null, keeping the other arguments unchanged. has_more=null means another page is uncertain; a final nonempty page can be followed by an empty page. Pages are live, not a stable snapshot; reverse reverses each page. limit defaults to 50 -(0 means default; -1 unlimited). node_search max_lines defaults to 3 per node; -1 +(0 means default; -1 unlimited). node_grep max_lines defaults to 3 per node; -1 returns all matching lines. Use node_read for complete bodies. +node_search pages with an opaque cursor instead: repeat the same query with +next_cursor as cursor until next_cursor is absent (text output ends with a +`next cursor:` line while more remain). An omitted or 0 limit lets the Hub +choose the page size; -1 is not accepted. Hits are ordered by score, then KEG, then node id; +mode reports `keyword` when the Hub ranked without semantic matching. A KEG +outside the flight's cover is never searched, and a Hub without ranked search +fails with a message pointing at node_grep. + keg_search returns warnings, partial, and truncated. flight_search returns truncated. Refine a truncated metadata query; neither search has a cursor. Both search the connection-pinned Hub, never other configured Hubs. diff --git a/integrations/content/troubleshooting.md b/integrations/content/troubleshooting.md index 5144fb8..dfcc1c1 100644 --- a/integrations/content/troubleshooting.md +++ b/integrations/content/troubleshooting.md @@ -24,8 +24,10 @@ the same operational information, including error diagnostics and recovery. - operationPerformed=null: the write outcome is unknown. Inspect state before retrying, including possible creations; do not assume nothing happened. - Missing listing rows: follow next_offset until null. For metadata search, - refine a query marked truncated. node_search limits matched lines; node_read - reads full - bodies. A tool absent from tools/list is unavailable, not a failed operation. + refine a query marked truncated. node_search pages with next_cursor; + node_grep limits matched lines; node_read reads full bodies. A tool absent + from tools/list is unavailable, not a failed operation. +- "ranked search unavailable" (NOT_SUPPORTED): the Hub predates ranked + search. Use node_grep with a regex for the terms, one KEG at a time. - Suspected stale indexes: inspect index_list/index_read and keg_check. Rebuild with index_rebuild only when warranted and authorized; reads never repair indexes. diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 4757f42..8795ee1 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -81,7 +81,7 @@ it. | --- | --- | | `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | | `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | | `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | @@ -99,7 +99,8 @@ accessible transitive descendants. | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp__tapper__node_search` | Regex search over node content. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | +| `mcp__tapper__node_search` | Find the nodes most relevant to a query, ranked by the Hub across every KEG the call may read, one `keg`, or the listed `kegs`. Takes plain terms, `"quoted phrases"`, and `-exclusions`. Hits carry `ref`, `score`, `title`, matched `fields`, and a `snippet` with highlights; follow `next_cursor`. | +| `mcp__tapper__node_grep` | Regex match over node content lines in one KEG, in node id order, for exact identifiers, symbols, and audits. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | | `mcp__tapper__tag_list` | List tags or filter nodes by a boolean expression over tags, attributes, and dot-prefix stats fields (for example `tapper and .created>2026-01-01`). | | `mcp__tapper__node_list` | List nodes in a keg with optional filters. | | `mcp__tapper__node_read` | Read one or more nodes. Each structured row pairs `node_id` and `hash` with that node's `content` and `meta`, so a read feeds straight into `node_edit`. Supports `meta_only`, `content_only`, `stats_only`, and `query` expression selection as an alternative to explicit node IDs. | @@ -109,8 +110,10 @@ accessible transitive descendants. | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | | `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | -Pass `id_only: true` to `node_search` and `tag_list` when you only need IDs for follow-up -reads — it keeps token consumption bounded on large result sets. +Start topic lookups with `node_search`; reach for `node_grep` when you need +every line matching an exact pattern. Pass `id_only: true` to `node_grep` and +`tag_list` when you only need IDs for follow-up reads — it keeps token +consumption bounded on large result sets. ## Query expressions @@ -190,9 +193,14 @@ from `node_read`, and either document may be omitted to leave it untouched: ```json {"nodes": [{"node_id": "12", "content": "# Revised\n\nBody", "expected_content_hash": "CONTENT_HASH_FROM_READ"}]} -{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ"}]} +{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ", "schema": "document"}]} ``` +On a strict keg (the default for new kegs) every `node_create` item and every +`node_edit` item that supplies `meta` needs `schema`; `type` in `meta` alone does +not count. A content-only edit keeps the node's current type and is validated +against it, so it needs no `schema`. + `node_delete` carries only ids and the combined hashes: ```json @@ -243,14 +251,22 @@ Text content is JSON rendered from the same public object as structuredContent. The message field preserves prose, diagnostics, and legacy formatted output. Images remain in MCP image content blocks alongside JSON metadata. -For node_list, node_search, tag_list, node_links, and node_backlinks, follow +For node_list, node_grep, tag_list, node_links, and node_backlinks, follow next_offset until null, keeping the other arguments unchanged. has_more=null means another page is uncertain; a final nonempty page can be followed by an empty page. Pages are live, not a stable snapshot; reverse reverses each page. limit defaults to 50 -(0 means default; -1 unlimited). node_search max_lines defaults to 3 per node; -1 +(0 means default; -1 unlimited). node_grep max_lines defaults to 3 per node; -1 returns all matching lines. Use node_read for complete bodies. +node_search pages with an opaque cursor instead: repeat the same query with +next_cursor as cursor until next_cursor is absent (text output ends with a +`next cursor:` line while more remain). An omitted or 0 limit lets the Hub +choose the page size; -1 is not accepted. Hits are ordered by score, then KEG, then node id; +mode reports `keyword` when the Hub ranked without semantic matching. A KEG +outside the flight's cover is never searched, and a Hub without ranked search +fails with a message pointing at node_grep. + keg_search returns warnings, partial, and truncated. flight_search returns truncated. Refine a truncated metadata query; neither search has a cursor. Both search the connection-pinned Hub, never other configured Hubs. @@ -420,8 +436,10 @@ the same operational information, including error diagnostics and recovery. - operationPerformed=null: the write outcome is unknown. Inspect state before retrying, including possible creations; do not assume nothing happened. - Missing listing rows: follow next_offset until null. For metadata search, - refine a query marked truncated. node_search limits matched lines; node_read - reads full - bodies. A tool absent from tools/list is unavailable, not a failed operation. + refine a query marked truncated. node_search pages with next_cursor; + node_grep limits matched lines; node_read reads full bodies. A tool absent + from tools/list is unavailable, not a failed operation. +- "ranked search unavailable" (NOT_SUPPORTED): the Hub predates ranked + search. Use node_grep with a regex for the terms, one KEG at a time. - Suspected stale indexes: inspect index_list/index_read and keg_check. Rebuild with index_rebuild only when warranted and authorized; reads never repair indexes. diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 4757f42..8795ee1 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -81,7 +81,7 @@ it. | --- | --- | | `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | | `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | | `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | @@ -99,7 +99,8 @@ accessible transitive descendants. | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp__tapper__node_search` | Regex search over node content. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | +| `mcp__tapper__node_search` | Find the nodes most relevant to a query, ranked by the Hub across every KEG the call may read, one `keg`, or the listed `kegs`. Takes plain terms, `"quoted phrases"`, and `-exclusions`. Hits carry `ref`, `score`, `title`, matched `fields`, and a `snippet` with highlights; follow `next_cursor`. | +| `mcp__tapper__node_grep` | Regex match over node content lines in one KEG, in node id order, for exact identifiers, symbols, and audits. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | | `mcp__tapper__tag_list` | List tags or filter nodes by a boolean expression over tags, attributes, and dot-prefix stats fields (for example `tapper and .created>2026-01-01`). | | `mcp__tapper__node_list` | List nodes in a keg with optional filters. | | `mcp__tapper__node_read` | Read one or more nodes. Each structured row pairs `node_id` and `hash` with that node's `content` and `meta`, so a read feeds straight into `node_edit`. Supports `meta_only`, `content_only`, `stats_only`, and `query` expression selection as an alternative to explicit node IDs. | @@ -109,8 +110,10 @@ accessible transitive descendants. | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | | `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | -Pass `id_only: true` to `node_search` and `tag_list` when you only need IDs for follow-up -reads — it keeps token consumption bounded on large result sets. +Start topic lookups with `node_search`; reach for `node_grep` when you need +every line matching an exact pattern. Pass `id_only: true` to `node_grep` and +`tag_list` when you only need IDs for follow-up reads — it keeps token +consumption bounded on large result sets. ## Query expressions @@ -190,9 +193,14 @@ from `node_read`, and either document may be omitted to leave it untouched: ```json {"nodes": [{"node_id": "12", "content": "# Revised\n\nBody", "expected_content_hash": "CONTENT_HASH_FROM_READ"}]} -{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ"}]} +{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ", "schema": "document"}]} ``` +On a strict keg (the default for new kegs) every `node_create` item and every +`node_edit` item that supplies `meta` needs `schema`; `type` in `meta` alone does +not count. A content-only edit keeps the node's current type and is validated +against it, so it needs no `schema`. + `node_delete` carries only ids and the combined hashes: ```json @@ -243,14 +251,22 @@ Text content is JSON rendered from the same public object as structuredContent. The message field preserves prose, diagnostics, and legacy formatted output. Images remain in MCP image content blocks alongside JSON metadata. -For node_list, node_search, tag_list, node_links, and node_backlinks, follow +For node_list, node_grep, tag_list, node_links, and node_backlinks, follow next_offset until null, keeping the other arguments unchanged. has_more=null means another page is uncertain; a final nonempty page can be followed by an empty page. Pages are live, not a stable snapshot; reverse reverses each page. limit defaults to 50 -(0 means default; -1 unlimited). node_search max_lines defaults to 3 per node; -1 +(0 means default; -1 unlimited). node_grep max_lines defaults to 3 per node; -1 returns all matching lines. Use node_read for complete bodies. +node_search pages with an opaque cursor instead: repeat the same query with +next_cursor as cursor until next_cursor is absent (text output ends with a +`next cursor:` line while more remain). An omitted or 0 limit lets the Hub +choose the page size; -1 is not accepted. Hits are ordered by score, then KEG, then node id; +mode reports `keyword` when the Hub ranked without semantic matching. A KEG +outside the flight's cover is never searched, and a Hub without ranked search +fails with a message pointing at node_grep. + keg_search returns warnings, partial, and truncated. flight_search returns truncated. Refine a truncated metadata query; neither search has a cursor. Both search the connection-pinned Hub, never other configured Hubs. @@ -420,8 +436,10 @@ the same operational information, including error diagnostics and recovery. - operationPerformed=null: the write outcome is unknown. Inspect state before retrying, including possible creations; do not assume nothing happened. - Missing listing rows: follow next_offset until null. For metadata search, - refine a query marked truncated. node_search limits matched lines; node_read - reads full - bodies. A tool absent from tools/list is unavailable, not a failed operation. + refine a query marked truncated. node_search pages with next_cursor; + node_grep limits matched lines; node_read reads full bodies. A tool absent + from tools/list is unavailable, not a failed operation. +- "ranked search unavailable" (NOT_SUPPORTED): the Hub predates ranked + search. Use node_grep with a regex for the terms, one KEG at a time. - Suspected stale indexes: inspect index_list/index_read and keg_check. Rebuild with index_rebuild only when warranted and authorized; reads never repair indexes. diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 4757f42..8795ee1 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -81,7 +81,7 @@ it. | --- | --- | | `mcp__tapper__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, and current instructions. | | `mcp__tapper__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | -| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Creation takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it); listings label KEGv2 KEGs. | | `mcp__tapper__flight_create`, `mcp__tapper__flight_edit`, `mcp__tapper__flight_delete` | Manage Hub flights when the selected flight grants `manage_flights`; edits and deletes require the manifest hash returned by `flight_read`, and normal Hub ACLs still apply. | | `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Identity-readable discovery; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | @@ -99,7 +99,8 @@ accessible transitive descendants. | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp__tapper__node_search` | Regex search over node content. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | +| `mcp__tapper__node_search` | Find the nodes most relevant to a query, ranked by the Hub across every KEG the call may read, one `keg`, or the listed `kegs`. Takes plain terms, `"quoted phrases"`, and `-exclusions`. Hits carry `ref`, `score`, `title`, matched `fields`, and a `snippet` with highlights; follow `next_cursor`. | +| `mcp__tapper__node_grep` | Regex match over node content lines in one KEG, in node id order, for exact identifiers, symbols, and audits. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. | | `mcp__tapper__tag_list` | List tags or filter nodes by a boolean expression over tags, attributes, and dot-prefix stats fields (for example `tapper and .created>2026-01-01`). | | `mcp__tapper__node_list` | List nodes in a keg with optional filters. | | `mcp__tapper__node_read` | Read one or more nodes. Each structured row pairs `node_id` and `hash` with that node's `content` and `meta`, so a read feeds straight into `node_edit`. Supports `meta_only`, `content_only`, `stats_only`, and `query` expression selection as an alternative to explicit node IDs. | @@ -109,8 +110,10 @@ accessible transitive descendants. | `mcp__tapper__keg_settings_read` | Read targeted title, description, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. | | `mcp__tapper__keg_search` | Case-insensitive literal search across identity-accessible canonical refs, titles, and descriptions. Returns at most 50 rows and never grants operational access. | -Pass `id_only: true` to `node_search` and `tag_list` when you only need IDs for follow-up -reads — it keeps token consumption bounded on large result sets. +Start topic lookups with `node_search`; reach for `node_grep` when you need +every line matching an exact pattern. Pass `id_only: true` to `node_grep` and +`tag_list` when you only need IDs for follow-up reads — it keeps token +consumption bounded on large result sets. ## Query expressions @@ -190,9 +193,14 @@ from `node_read`, and either document may be omitted to leave it untouched: ```json {"nodes": [{"node_id": "12", "content": "# Revised\n\nBody", "expected_content_hash": "CONTENT_HASH_FROM_READ"}]} -{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ"}]} +{"nodes": [{"node_id": "12", "meta": "type: document\n", "expected_meta_hash": "META_HASH_FROM_READ", "schema": "document"}]} ``` +On a strict keg (the default for new kegs) every `node_create` item and every +`node_edit` item that supplies `meta` needs `schema`; `type` in `meta` alone does +not count. A content-only edit keeps the node's current type and is validated +against it, so it needs no `schema`. + `node_delete` carries only ids and the combined hashes: ```json @@ -243,14 +251,22 @@ Text content is JSON rendered from the same public object as structuredContent. The message field preserves prose, diagnostics, and legacy formatted output. Images remain in MCP image content blocks alongside JSON metadata. -For node_list, node_search, tag_list, node_links, and node_backlinks, follow +For node_list, node_grep, tag_list, node_links, and node_backlinks, follow next_offset until null, keeping the other arguments unchanged. has_more=null means another page is uncertain; a final nonempty page can be followed by an empty page. Pages are live, not a stable snapshot; reverse reverses each page. limit defaults to 50 -(0 means default; -1 unlimited). node_search max_lines defaults to 3 per node; -1 +(0 means default; -1 unlimited). node_grep max_lines defaults to 3 per node; -1 returns all matching lines. Use node_read for complete bodies. +node_search pages with an opaque cursor instead: repeat the same query with +next_cursor as cursor until next_cursor is absent (text output ends with a +`next cursor:` line while more remain). An omitted or 0 limit lets the Hub +choose the page size; -1 is not accepted. Hits are ordered by score, then KEG, then node id; +mode reports `keyword` when the Hub ranked without semantic matching. A KEG +outside the flight's cover is never searched, and a Hub without ranked search +fails with a message pointing at node_grep. + keg_search returns warnings, partial, and truncated. flight_search returns truncated. Refine a truncated metadata query; neither search has a cursor. Both search the connection-pinned Hub, never other configured Hubs. @@ -420,8 +436,10 @@ the same operational information, including error diagnostics and recovery. - operationPerformed=null: the write outcome is unknown. Inspect state before retrying, including possible creations; do not assume nothing happened. - Missing listing rows: follow next_offset until null. For metadata search, - refine a query marked truncated. node_search limits matched lines; node_read - reads full - bodies. A tool absent from tools/list is unavailable, not a failed operation. + refine a query marked truncated. node_search pages with next_cursor; + node_grep limits matched lines; node_read reads full bodies. A tool absent + from tools/list is unavailable, not a failed operation. +- "ranked search unavailable" (NOT_SUPPORTED): the Hub predates ranked + search. Use node_grep with a regex for the terms, one KEG at a time. - Suspected stale indexes: inspect index_list/index_read and keg_check. Rebuild with index_rebuild only when warranted and authorized; reads never repair indexes. diff --git a/pkg/cli/cmd_grep.go b/pkg/cli/cmd_grep.go index 3aaef33..71ed6cd 100644 --- a/pkg/cli/cmd_grep.go +++ b/pkg/cli/cmd_grep.go @@ -11,9 +11,12 @@ func NewGrepCmd(deps *Deps) *cobra.Command { var opts tapper.GrepOptions cmd := &cobra.Command{ - Use: "search QUERY", - Short: "search node content by query", - Long: `Search node content with a regex and print matching lines grouped by node. + Use: "grep PATTERN", + Short: "match node content lines against a regex", + Long: `Match node content lines in one KEG against a regex and print the +matching lines grouped by node, in node id order. Use it for exact +identifiers, symbols, and audits; use tap node search to find the nodes +about a topic. With --format or --id-only, matching nodes are listed instead of their matching lines. diff --git a/pkg/cli/cmd_grep_test.go b/pkg/cli/cmd_grep_test.go index 9537ded..112a3f2 100644 --- a/pkg/cli/cmd_grep_test.go +++ b/pkg/cli/cmd_grep_test.go @@ -17,18 +17,18 @@ func TestGrepCommand_TableDrivenErrors(t *testing.T) { }{ { name: "missing_query", - args: []string{"node", "search"}, + args: []string{"node", "grep"}, expectedErr: "accepts 1 arg", }, { name: "invalid_regex", - args: []string{"node", "search", "["}, + args: []string{"node", "grep", "["}, fixture: strPtr("joe"), expectedErr: "invalid query regex", }, { name: "missing_alias", - args: []string{"node", "search", "anything", "--keg", "missing"}, + args: []string{"node", "grep", "anything", "--keg", "missing"}, fixture: strPtr("joe"), expectedErr: "keg not initialized", }, @@ -69,7 +69,7 @@ func TestGrepCommand_DefaultOutputShowsMatchingLinesByNode(t *testing.T) { ) require.Equal(t, "2", secondID) - res := NewProcess(t, false, "node", "search", "fire").Run(sb.Context(), sb.Runtime()) + res := NewProcess(t, false, "node", "grep", "fire").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) expected := strings.Join([]string{ @@ -90,15 +90,15 @@ func TestGrepCommand_IgnoreCaseIdOnlyReverseAndFormat(t *testing.T) { createNodeWithBodyFromStdin(t, sb, "# Alpha\n\nfire one\n") createNodeWithBodyFromStdin(t, sb, "# Beta\n\nwildfire item\n") - idOnly := NewProcess(t, false, "node", "search", "FIRE", "--ignore-case", "--id-only").Run(sb.Context(), sb.Runtime()) + idOnly := NewProcess(t, false, "node", "grep", "FIRE", "--ignore-case", "--id-only").Run(sb.Context(), sb.Runtime()) require.NoError(t, idOnly.Err) require.Equal(t, "1\n2", strings.TrimSpace(string(idOnly.Stdout))) - reversed := NewProcess(t, false, "node", "search", "FIRE", "--ignore-case", "--id-only", "--reverse").Run(sb.Context(), sb.Runtime()) + reversed := NewProcess(t, false, "node", "grep", "FIRE", "--ignore-case", "--id-only", "--reverse").Run(sb.Context(), sb.Runtime()) require.NoError(t, reversed.Err) require.Equal(t, "2\n1", strings.TrimSpace(string(reversed.Stdout))) - formatted := NewProcess(t, false, "node", "search", "FIRE", "--ignore-case", "--format", "%i|%t").Run(sb.Context(), sb.Runtime()) + formatted := NewProcess(t, false, "node", "grep", "FIRE", "--ignore-case", "--format", "%i|%t").Run(sb.Context(), sb.Runtime()) require.NoError(t, formatted.Err) require.Equal(t, "1|Alpha\n2|Beta", strings.TrimSpace(string(formatted.Stdout))) } @@ -107,7 +107,7 @@ func TestGrepCommand_NoMatchesReturnsEmptyOutput(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - res := NewProcess(t, false, "node", "search", "not-found-token-zzzx", "--keg", "personal").Run(sb.Context(), sb.Runtime()) + res := NewProcess(t, false, "node", "grep", "not-found-token-zzzx", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) require.Equal(t, "", strings.TrimSpace(string(res.Stdout))) } @@ -121,17 +121,17 @@ func TestGrepCommand_OffsetSkipsMatchingNodes(t *testing.T) { createNodeWithBodyFromStdin(t, sb, "# Gamma\n\nfire three\n") // Without offset: all three match "fire" (nodes 1,2,3). - all := NewProcess(t, false, "node", "search", "fire", "--id-only").Run(sb.Context(), sb.Runtime()) + all := NewProcess(t, false, "node", "grep", "fire", "--id-only").Run(sb.Context(), sb.Runtime()) require.NoError(t, all.Err) require.Equal(t, "1\n2\n3", strings.TrimSpace(string(all.Stdout))) // Offset 1: skip first match. - offset := NewProcess(t, false, "node", "search", "fire", "--id-only", "--offset", "1").Run(sb.Context(), sb.Runtime()) + offset := NewProcess(t, false, "node", "grep", "fire", "--id-only", "--offset", "1").Run(sb.Context(), sb.Runtime()) require.NoError(t, offset.Err) require.Equal(t, "2\n3", strings.TrimSpace(string(offset.Stdout))) // Offset 1 skips node 1, leaving (2,3). Limit 2 takes first 2: (2,3). - combined := NewProcess(t, false, "node", "search", "fire", "--id-only", "-n", "2", "--offset", "1").Run(sb.Context(), sb.Runtime()) + combined := NewProcess(t, false, "node", "grep", "fire", "--id-only", "-n", "2", "--offset", "1").Run(sb.Context(), sb.Runtime()) require.NoError(t, combined.Err) require.Equal(t, "2\n3", strings.TrimSpace(string(combined.Stdout))) } @@ -147,14 +147,14 @@ func TestGrepCommand_MaxLinesTruncatesPerNode(t *testing.T) { ) // Without --max-lines: all 4 lines match. - all := NewProcess(t, false, "node", "search", "fire").Run(sb.Context(), sb.Runtime()) + all := NewProcess(t, false, "node", "grep", "fire").Run(sb.Context(), sb.Runtime()) require.NoError(t, all.Err) lines := strings.Split(strings.TrimSpace(string(all.Stdout)), "\n") // Header + 4 match lines = 5 lines total. require.Equal(t, 5, len(lines)) // With --max-lines 2: only first 2 match lines per node. - truncated := NewProcess(t, false, "node", "search", "fire", "--max-lines", "2").Run(sb.Context(), sb.Runtime()) + truncated := NewProcess(t, false, "node", "grep", "fire", "--max-lines", "2").Run(sb.Context(), sb.Runtime()) require.NoError(t, truncated.Err) tlines := strings.Split(strings.TrimSpace(string(truncated.Stdout)), "\n") // Header + 2 match lines = 3 lines total. diff --git a/pkg/cli/cmd_keg_create.go b/pkg/cli/cmd_keg_create.go index 69f637b..743db1c 100644 --- a/pkg/cli/cmd_keg_create.go +++ b/pkg/cli/cmd_keg_create.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" ) @@ -18,6 +19,7 @@ func newKegCreateCmd(deps *Deps) *cobra.Command { tap keg create @acme/notes tap keg create @acme/engineering --title "Engineering" tap keg create @alice/notes --hub enterprise +tap keg create @acme/graph --kegv 2026-09 `), } configureKegCreateCmd(deps, cmd) @@ -62,4 +64,11 @@ func configureKegCreateCmd(deps *Deps, cmd *cobra.Command) { cmd.Flags().StringVar(&options.Hub, "hub", "", "configured hub name") cmd.Flags().StringVar(&options.Title, "title", "", "human-readable KEG title") cmd.Flags().StringVar(&options.Visibility, "visibility", "", "KEG visibility: private or public") + cmd.Flags().StringVar(&options.KegVersion, "kegv", "", "immutable KEG version: "+keg.KegVersionV1+" (KEGv1, default) or "+keg.KegVersionV2+" (KEGv2 preview, where the hub enables it)") + _ = cmd.RegisterFlagCompletionFunc("kegv", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{ + keg.KegVersionV1 + "\tKEGv1 (Markdown)", + keg.KegVersionV2 + "\tKEGv2 preview", + }, cobra.ShellCompDirectiveNoFileComp + }) } diff --git a/pkg/cli/cmd_node.go b/pkg/cli/cmd_node.go index b6edb37..1ca6511 100644 --- a/pkg/cli/cmd_node.go +++ b/pkg/cli/cmd_node.go @@ -7,7 +7,8 @@ import "github.com/spf13/cobra" // // tap node read 12 // tap node list --query 'tag:draft' -// tap node search 'TODO' +// tap node search 'release plan' +// tap node grep 'TODO' // tap node create < note.md // tap node edit 12 // tap node move 12 40 @@ -20,6 +21,7 @@ func NewNodeCmd(deps *Deps) *cobra.Command { cmd.AddCommand( NewCatCmd(deps), NewListCmd(deps), + NewSearchCmd(deps), NewGrepCmd(deps), NewLinksCmd(deps), NewBacklinksCmd(deps), diff --git a/pkg/cli/cmd_search.go b/pkg/cli/cmd_search.go new file mode 100644 index 0000000..f1c9e73 --- /dev/null +++ b/pkg/cli/cmd_search.go @@ -0,0 +1,108 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/jlrickert/tapper/pkg/tapper" +) + +// NewSearchCmd returns `tap node search`, the peer of the node_search tool. +func NewSearchCmd(deps *Deps) *cobra.Command { + var ( + opts tapper.SearchOptions + idOnly bool + asJSON bool + ) + + cmd := &cobra.Command{ + Use: "search QUERY...", + Short: "find the nodes most relevant to a query", + Long: `Rank nodes by relevance to a query across every KEG you can read on the +hub, one KEG (--keg), or the listed KEGs (--kegs). The query takes plain +terms, "quoted phrases", and -exclusions; the words of a multi-word query +are joined with spaces. Quote the query, or put it after --, when it starts +an exclusion, so the shell and tap pass it through intact. + +Each hit prints its reference, score, title, and a snippet with matches in +**bold** (or highlighted on a terminal), tab-separated. When more hits +remain, the cursor for the next page is printed to stderr; pass it back +with --cursor and the same query. Use tap node grep for exact regex +matching.`, + Example: strings.TrimSpace(` +tap node search release plan +tap node search '"billing migration" -draft' --keg @acme/engineering +tap node search deploy --kegs @acme/ops,@acme/eng --limit 5 +tap node search deploy --cursor "$NEXT" +tap node search -- deploy -staging +`), + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Query = strings.Join(args, " ") + applyKegTargetProfile(deps, &opts.KegTargetOptions) + + resp, err := deps.Tap.Search(cmd.Context(), opts) + if err != nil { + return err + } + out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + if asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(resp) + } + for _, warning := range resp.Warnings { + fmt.Fprintln(errOut, "warning: "+warning) + } + if idOnly { + for _, hit := range resp.Hits { + fmt.Fprintln(out, hit.Ref) + } + } else { + open, close := searchHighlightMarkers(deps) + for _, line := range tapper.SearchHitLines(resp.Hits, open, close) { + fmt.Fprintln(out, line) + } + } + if resp.NextCursor != "" { + fmt.Fprintln(errOut, tapper.SearchNextCursorLine(resp.NextCursor)) + } + return nil + }, + } + + cmd.Flags().StringSliceVar(&opts.Kegs, "kegs", nil, "narrow the search to these @namespace/keg references, at most 50 (comma separated or repeated)") + mustRegisterFlagCompletion(cmd, "kegs", kegListFlagCompletionFunc(deps)) + cmd.Flags().StringVar(&opts.Cursor, "cursor", "", "continue from a previous page's next cursor") + cmd.Flags().IntVarP(&opts.Limit, "limit", "n", 0, "maximum hits to return (0 lets the hub choose)") + cmd.Flags().BoolVar(&idOnly, "id-only", false, "print only node references") + cmd.Flags().BoolVar(&asJSON, "json", false, "print the hub's ranked response as JSON") + cmd.MarkFlagsMutuallyExclusive("id-only", "json") + return cmd +} + +// searchHighlightMarkers bolds matches on a terminal and marks them +// **like this** elsewhere, matching node_search's rendering. +func searchHighlightMarkers(deps *Deps) (string, string) { + if deps != nil && deps.Runtime != nil && deps.Runtime.Stream().IsTTY { + return "\x1b[1m", "\x1b[0m" + } + return "**", "**" +} + +// kegListFlagCompletionFunc completes the last item of a comma-separated KEG +// list, keeping the items already typed. +func kegListFlagCompletionFunc(deps *Deps) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cut := strings.LastIndex(toComplete, ",") + 1 + done, last := toComplete[:cut], toComplete[cut:] + candidates := kegFlagCompletions(cmd.Context(), deps, last) + for i, candidate := range candidates { + candidates[i] = done + candidate + } + return candidates, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } +} diff --git a/pkg/cli/cmd_search_completion_test.go b/pkg/cli/cmd_search_completion_test.go new file mode 100644 index 0000000..717dc1b --- /dev/null +++ b/pkg/cli/cmd_search_completion_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSearchCompletion_KegsFlag(t *testing.T) { + t.Parallel() + res := runCompletionViaProcess(t, "node", "search", "x", "--kegs", "@team/a,") + require.NoError(t, res.Err) + // :6 == ShellCompDirectiveNoFileComp|ShellCompDirectiveNoSpace, so a list + // keeps growing after a comma. + require.Contains(t, string(res.Stdout), ":6") +} + +func TestKegCreateCompletion_KegvFlag(t *testing.T) { + t.Parallel() + res := runCompletionViaProcess(t, "keg", "create", "@team/x", "--kegv", "") + require.NoError(t, res.Err) + out := string(res.Stdout) + require.Contains(t, out, "2025-07") + require.Contains(t, out, "2026-09") + require.Contains(t, out, ":4") +} diff --git a/pkg/cli/cmd_search_test.go b/pkg/cli/cmd_search_test.go new file mode 100644 index 0000000..22c2ebc --- /dev/null +++ b/pkg/cli/cmd_search_test.go @@ -0,0 +1,107 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapapi" +) + +const cliSearchReply = `{"hits":[` + + `{"ref":"keg:@team/notes/12","keg":"@team/notes","node_id":"12","title":"Festival trip","score":4.25,"fields":["title"],"snippet":"festival in Los Angeles","highlights":[{"start":0,"end":8}]},` + + `{"ref":"keg:@team/eng/3","keg":"@team/eng","node_id":"3","title":"Release","score":1,"fields":["body"]}` + + `],"next_cursor":"c2","mode":"keyword","warnings":["one KEG was skipped"]}` + +func newSearchHubSandbox(t *testing.T) (*searchHubRecorder, func(args ...string) searchRun) { + t.Helper() + rec := &searchHubRecorder{} + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1"+tapapi.PathSearch { + http.NotFound(w, r) + return + } + var req tapapi.SearchRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + rec.mu.Lock() + rec.requests = append(rec.requests, req) + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Header().Set(tapapi.SearchRouteHeader, tapapi.SearchRouteHeaderValue) + _, _ = w.Write([]byte(cliSearchReply)) + })) + t.Cleanup(srv.Close) + sb := NewSandbox(t) + sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: test\nhubs:\n test:\n url: %s\n token: test-token\n", srv.URL)), 0o644) + return rec, func(args ...string) searchRun { + res := NewProcess(t, false, args...).Run(sb.Context(), sb.Runtime()) + return searchRun{Err: res.Err, Stdout: string(res.Stdout), Stderr: string(res.Stderr)} + } +} + +type searchHubRecorder struct { + mu sync.Mutex + requests []tapapi.SearchRequest +} + +func (r *searchHubRecorder) recorded() []tapapi.SearchRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]tapapi.SearchRequest(nil), r.requests...) +} + +// searchRun is a string view of a finished CLI process. +type searchRun struct { + Err error + Stdout, Stderr string +} + +func TestSearchCommand_PrintsRankedHits(t *testing.T) { + t.Parallel() + rec, run := newSearchHubSandbox(t) + + res := run("node", "search", "festival", "trip", "--kegs", "@team/notes,@team/eng", "--limit", "2", "--cursor", "c1") + require.NoError(t, res.Err, "stderr=%q", res.Stderr) + require.Equal(t, "keg:@team/notes/12\t4.250\tFestival trip\t**festival** in Los Angeles\n"+ + "keg:@team/eng/3\t1.000\tRelease\t\n", res.Stdout) + require.Contains(t, res.Stderr, "warning: one KEG was skipped") + require.Contains(t, res.Stderr, "next cursor: c2") + require.Equal(t, []tapapi.SearchRequest{{ + Query: "festival trip", Kegs: []string{"@team/notes", "@team/eng"}, Cursor: "c1", Limit: 2, + }}, rec.recorded()) +} + +func TestSearchCommand_IDOnlyAndJSON(t *testing.T) { + t.Parallel() + _, run := newSearchHubSandbox(t) + + res := run("node", "search", "festival", "--id-only") + require.NoError(t, res.Err, "stderr=%q", res.Stderr) + require.Equal(t, "keg:@team/notes/12\nkeg:@team/eng/3\n", res.Stdout) + + res = run("node", "search", "festival", "--json") + require.NoError(t, res.Err, "stderr=%q", res.Stderr) + var resp tapapi.SearchResponse + require.NoError(t, json.Unmarshal([]byte(res.Stdout), &resp)) + require.Len(t, resp.Hits, 2) + require.Equal(t, "c2", resp.NextCursor) + require.Equal(t, tapapi.SearchModeKeyword, resp.Mode) + require.NotContains(t, res.Stderr, "next cursor", "--json carries the cursor in the document") + + res = run("node", "search", "festival", "--json", "--id-only") + require.Error(t, res.Err) +} + +func TestSearchCommand_RequiresAQuery(t *testing.T) { + t.Parallel() + _, run := newSearchHubSandbox(t) + + res := run("node", "search") + require.Error(t, res.Err) + require.Contains(t, res.Stderr, "requires at least 1 arg") +} diff --git a/pkg/keg/constants.go b/pkg/keg/constants.go index b0a64e6..78e18c9 100644 --- a/pkg/keg/constants.go +++ b/pkg/keg/constants.go @@ -28,3 +28,21 @@ var ( // FormatRST is the short format identifier for reStructuredText content. FormatRST = "rst" ) + +// KEG versions select the storage format a Hub KEG is created with. A KEG's +// version is immutable and owned by the Hub's catalog. It is not the settings +// document version above, even though KEGv1 and the current settings document +// share the value 2025-07. +const ( + // KegVersionV1 is the Markdown-first KEG format, the Hub's default. + KegVersionV1 = "2025-07" + + // KegVersionV2 is the graph-authoritative KEG format (preview). + KegVersionV2 = "2026-09" +) + +// ValidKegVersion reports whether v names a known KEG version. The empty +// string is not a version: callers treat it as "let the Hub choose". +func ValidKegVersion(v string) bool { + return v == KegVersionV1 || v == KegVersionV2 +} diff --git a/pkg/keg/errors.go b/pkg/keg/errors.go index 4394f15..379cd5d 100644 --- a/pkg/keg/errors.go +++ b/pkg/keg/errors.go @@ -21,6 +21,12 @@ var ( ErrNotSupported = errors.New("not supported") ErrSchemaInvalid = errors.New("schema validation failed") + // ErrKegVersionUnsupported reports an operation the target KEG's version + // does not offer, such as a KEGv1 node operation against a KEGv2 KEG. It + // is deliberately distinct from ErrNotSupported, which callers treat as an + // absent optional capability and degrade around silently. + ErrKegVersionUnsupported = errors.New("operation not supported for this keg version") + // ErrInvalidAssetName is returned when a node asset name is not a single safe // path component (empty, ".", "..", contains a path separator, or absolute). // Such names could let filepath.Join resolve outside the keg root, so they diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index d289a52..3f0c31a 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -124,8 +124,13 @@ type RelatedNodesResult struct { } type KegInfo struct { + // Settings and Summary may be nil for a KEG version whose engine has no + // KEGv1 settings document or node summary. Settings *Settings `json:"settings"` Summary *KegSummary `json:"summary"` + // KegVersion is the KEG's immutable storage version, reported by a Hub + // that versions its KEGs. Empty means the Hub predates versions (KEGv1). + KegVersion string `json:"kegv,omitempty"` } type DoctorIssue struct { diff --git a/pkg/keg/keg_batch.go b/pkg/keg/keg_batch.go index 68f7dfa..355f71c 100644 --- a/pkg/keg/keg_batch.go +++ b/pkg/keg/keg_batch.go @@ -219,6 +219,9 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) if stats == nil { stats = &NodeStats{} } + // Without submitted meta, meta is the stored metadata: a content-only + // edit keeps its type. Read it before updateMeta touches meta. + kept := schemaTypeCandidateInherited(meta) now := k.Runtime.Clock().Now() data := &NodeData{ID: opts.ID, Content: content, Meta: meta, Stats: stats} if err := data.updateMeta(ctx, k.Runtime, &now); err != nil { @@ -229,6 +232,8 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) candidates := make([]schemaTypeCandidate, 0, 1) if opts.HasMeta { candidates = append(candidates, schemaTypeCandidateFromMeta("metadata", meta)) + } else { + candidates = append(candidates, kept) } validation, err := k.validateNodeWrite(ctx, schemaWriteUpdate, opts.ID, data, opts.Schema, candidates...) if err != nil && !errors.Is(err, ErrNotSupported) { diff --git a/pkg/keg/keg_local_content.go b/pkg/keg/keg_local_content.go index 0930a76..cb45856 100644 --- a/pkg/keg/keg_local_content.go +++ b/pkg/keg/keg_local_content.go @@ -67,13 +67,15 @@ func (k *LocalKeg) setContentNoDexWithOptions(ctx context.Context, id NodeId, da if stats == nil { stats = &NodeStats{} } + // Read the stored type before updateMeta folds frontmatter into meta. + kept := schemaTypeCandidateInherited(meta) now := k.Runtime.Clock().Now() proposed := &NodeData{ID: id, Content: content, Meta: meta, Stats: stats} if err := proposed.updateMeta(lockCtx, k.Runtime, &now); err != nil { return fmt.Errorf("failed to update node metadata from content: %w", err) } if _, err := k.validateNodeWrite(lockCtx, schemaWriteUpdate, id, proposed, opts.Schema, - schemaTypeCandidateFromFrontmatter("frontmatter", content)); err != nil { + schemaTypeCandidateFromFrontmatter("frontmatter", content), kept); err != nil { return err } diff --git a/pkg/keg/remote_errors.go b/pkg/keg/remote_errors.go index f4f0f72..c9654c8 100644 --- a/pkg/keg/remote_errors.go +++ b/pkg/keg/remote_errors.go @@ -24,6 +24,7 @@ const ( RemoteCodeLock = "LOCK" RemoteCodeLockTimeout = "LOCK_TIMEOUT" RemoteCodeNotSupported = "NOT_SUPPORTED" + RemoteCodeKegVersionUnsupported = "KEG_VERSION_UNSUPPORTED" RemoteCodeUnauthorized = "UNAUTHORIZED" RemoteCodeForbidden = "FORBIDDEN" RemoteCodeBadRequest = "BAD_REQUEST" @@ -53,6 +54,7 @@ var remoteCodeTable = []struct { {ErrLockTimeout, RemoteCodeLockTimeout, http.StatusConflict}, {ErrLock, RemoteCodeLock, http.StatusConflict}, {ErrNotSupported, RemoteCodeNotSupported, http.StatusNotImplemented}, + {ErrKegVersionUnsupported, RemoteCodeKegVersionUnsupported, http.StatusConflict}, {ErrOrientationStale, RemoteCodeOrientationStale, http.StatusConflict}, {ErrOrientationDenied, RemoteCodeOrientationDenied, http.StatusForbidden}, {ErrOrientationUnavailable, RemoteCodeOrientationUnavailable, http.StatusServiceUnavailable}, diff --git a/pkg/keg/remote_errors_test.go b/pkg/keg/remote_errors_test.go index d3c34be..76d07c3 100644 --- a/pkg/keg/remote_errors_test.go +++ b/pkg/keg/remote_errors_test.go @@ -22,3 +22,30 @@ func TestRemoteErrorInvalidImageRoundTrip(t *testing.T) { t.Fatalf("round-trip error = %v, want ErrInvalidImage", err) } } + +func TestRemoteErrorKegVersionUnsupportedRoundTrip(t *testing.T) { + code, status := keg.RemoteErrorCode(keg.ErrKegVersionUnsupported) + if code != keg.RemoteCodeKegVersionUnsupported || status != http.StatusConflict { + t.Fatalf("got (%q, %d), want (%q, %d)", code, status, keg.RemoteCodeKegVersionUnsupported, http.StatusConflict) + } + err := keg.RemoteErrorFromCode(code, status, "node operations are not available for KEGv2 kegs") + if !errors.Is(err, keg.ErrKegVersionUnsupported) { + t.Fatalf("round-trip error = %v, want ErrKegVersionUnsupported", err) + } + // Callers degrade silently around ErrNotSupported, so a version refusal + // must never read as an absent optional capability. + if errors.Is(err, keg.ErrNotSupported) { + t.Fatalf("version refusal %v must not match ErrNotSupported", err) + } +} + +func TestValidKegVersion(t *testing.T) { + for v, want := range map[string]bool{ + keg.KegVersionV1: true, keg.KegVersionV2: true, + "": false, "2027-01": false, " 2025-07": false, + } { + if got := keg.ValidKegVersion(v); got != want { + t.Errorf("ValidKegVersion(%q) = %v, want %v", v, got, want) + } + } +} diff --git a/pkg/keg/schema.go b/pkg/keg/schema.go index 7110875..33ce4ab 100644 --- a/pkg/keg/schema.go +++ b/pkg/keg/schema.go @@ -238,6 +238,20 @@ func IsSchemaInvalid(err error) bool { return errors.Is(err, ErrSchemaInvalid) } type schemaTypeCandidate struct { Source string Value string + // Inherited marks the type a node already carries when a write leaves + // its metadata alone. It satisfies a strict explicit-selection + // requirement but declares nothing, so it never conflicts with a + // selected schema. + Inherited bool +} + +// schemaTypeCandidateInherited is the existing node type a content-only +// edit keeps: under a strict blocking policy it stands in for an explicit +// selection, since the edit does not change the node's metadata. +func schemaTypeCandidateInherited(meta *NodeMeta) schemaTypeCandidate { + candidate := schemaTypeCandidateFromMeta("existing metadata", meta) + candidate.Inherited = true + return candidate } func schemaTypeCandidateFromAttrs(source string, attrs map[string]any) schemaTypeCandidate { @@ -299,6 +313,24 @@ func (k *LocalKeg) explicitSchemaRequired(ctx context.Context, op schemaWriteOpe return k.effectiveValidationMode(ctx, op) == ValidationModeBlock } +// schemaSelectionRequiredMessage explains a missing explicit selection in +// terms a caller can act on: the field to pass and the schemas this keg +// defines. Listing the schemas is best effort. +func (k *LocalKeg) schemaSelectionRequiredMessage(ctx context.Context, op schemaWriteOperation) string { + msg := "explicit schema selection is required: pass the schema field" + if op == schemaWriteUpdate { + msg += " (an edit that leaves metadata unchanged keeps the node's type and needs none)" + } + if names, err := k.listSchemas(ctx); err == nil { + if len(names) == 0 { + msg += "; this keg defines no schemas, so an admin must add one or relax schemaPolicy" + } else { + msg += "; this keg defines: " + strings.Join(names, ", ") + } + } + return msg +} + // projectNodeWrite resolves one live create/update's explicit schema // selection, applies it to the projected metadata, and validates the complete // projected node. Payload type declarations may agree with the selection, but @@ -308,11 +340,16 @@ func (k *LocalKeg) projectNodeWrite(ctx context.Context, op schemaWriteOperation selected := strings.TrimSpace(schema) if id.ID != 0 { var declared schemaTypeCandidate + inherited := "" for _, candidate := range candidates { candidate.Value = strings.TrimSpace(candidate.Value) if candidate.Value == "" { continue } + if candidate.Inherited { + inherited = candidate.Value + continue + } if declared.Value != "" && candidate.Value != declared.Value { return schemaSelectionError(id, selected, fmt.Sprintf("%s type %q conflicts with %s type %q", candidate.Source, candidate.Value, declared.Source, declared.Value)) } @@ -321,8 +358,11 @@ func (k *LocalKeg) projectNodeWrite(ctx context.Context, op schemaWriteOperation return schemaSelectionError(id, selected, fmt.Sprintf("selected schema %q conflicts with %s type %q", selected, candidate.Source, candidate.Value)) } } - if k.explicitSchemaRequired(ctx, op, id) && selected == "" { - return schemaSelectionError(id, "", "explicit schema selection is required") + // A write that keeps the node's existing type (a content-only edit + // whose content declares no other type) needs no selection. + keepsType := inherited != "" && (declared.Value == "" || declared.Value == inherited) + if selected == "" && !keepsType && k.explicitSchemaRequired(ctx, op, id) { + return schemaSelectionError(id, "", k.schemaSelectionRequiredMessage(ctx, op)) } if selected != "" { if node == nil { @@ -883,6 +923,8 @@ func (k *LocalKeg) validateNodePayload(ctx context.Context, payload NodeValidati if err != nil { return nil, err } + // Read the stored type before updateMeta folds frontmatter into meta. + kept := schemaTypeCandidateInherited(meta) node := &NodeData{ID: id, Content: content, Meta: meta, Stats: &NodeStats{}} if err := node.updateMeta(ctx, k.Runtime, nil); err != nil { return nil, err @@ -893,6 +935,8 @@ func (k *LocalKeg) validateNodePayload(ctx context.Context, payload NodeValidati } if payload.HasMeta { candidates = append(candidates, schemaTypeCandidateFromMeta("metadata", meta)) + } else { + candidates = append(candidates, kept) } return k.previewNodeWrite(ctx, schemaWriteUpdate, id, node, payload.Schema, candidates...) } diff --git a/pkg/keg/schema_selection_test.go b/pkg/keg/schema_selection_test.go index 51e0e82..55df7e0 100644 --- a/pkg/keg/schema_selection_test.go +++ b/pkg/keg/schema_selection_test.go @@ -140,7 +140,8 @@ func TestSchemaSelectionBatchFailureIsAtomic(t *testing.T) { require.NoError(t, err) _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{ {ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: one.Hash()}, - {ID: created[1].ID, Content: []byte("# Missing selection\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: two.Hash()}, + // A metadata edit needs a selection; a content-only edit would not. + {ID: created[1].ID, Meta: []byte("type: task\nowner: alice\n"), HasMeta: true, SnapshotBefore: true, ExpectedHash: two.Hash()}, }) require.ErrorIs(t, err, keg.ErrSchemaInvalid) var batchErr *keg.BatchMutationError @@ -195,6 +196,95 @@ func TestStrictSchemaSelectionRejectsLegacyMetadataMutation(t *testing.T) { require.Empty(t, meta.Tags()) } +// TestStrictSchemaSelectionEditRules pins the three strict edit cases for an +// agent: a content-only edit keeps the node's type, while a metadata-only edit +// and an edit of both parts need an explicit selection. +func TestStrictSchemaSelectionEditRules(t *testing.T) { + k, ctx := newSchemaSelectionKeg(t) + agentCtx := keg.WithValidationActor(ctx, keg.ValidationActorAgent) + created, err := k.Create(agentCtx, &keg.CreateOptions{Schema: "note", Body: []byte("# Typed\n")}) + require.NoError(t, err) + + update := func(opts keg.NodeUpdateOptions) error { + t.Helper() + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + opts.ID, opts.ExpectedHash = created.ID, view.Hash() + _, err = k.UpdateNodes(agentCtx, []keg.NodeUpdateOptions{opts}) + return err + } + typeOf := func() string { + t.Helper() + meta, err := k.GetMeta(ctx, created.ID) + require.NoError(t, err) + typeName, _ := meta.Get("type") + return typeName + } + + t.Run("content only keeps the type", func(t *testing.T) { + require.NoError(t, update(keg.NodeUpdateOptions{Content: []byte("# Body edit\n"), HasContent: true})) + require.Equal(t, "note", typeOf()) + content, err := k.GetContent(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Body edit\n", string(content)) + }) + + t.Run("content only is validated against the kept type", func(t *testing.T) { + err := update(keg.NodeUpdateOptions{Content: []byte("\n"), HasContent: true}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid) + require.Contains(t, err.Error(), "title") + }) + + t.Run("meta only needs a selection", func(t *testing.T) { + err := update(keg.NodeUpdateOptions{Meta: []byte("type: note\nowner: alice\n"), HasMeta: true}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid) + require.Contains(t, err.Error(), "explicit schema selection is required: pass the schema field") + require.Contains(t, err.Error(), "this keg defines: note, task") + require.NoError(t, update(keg.NodeUpdateOptions{Schema: "note", Meta: []byte("type: note\nowner: alice\n"), HasMeta: true})) + }) + + t.Run("both parts need a selection and follow it", func(t *testing.T) { + err := update(keg.NodeUpdateOptions{Content: []byte("# Both\n"), HasContent: true, Meta: []byte("type: note\n"), HasMeta: true}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid) + require.Contains(t, err.Error(), "explicit schema selection is required") + + err = update(keg.NodeUpdateOptions{Schema: "note", Content: []byte("\n"), HasContent: true, Meta: []byte("type: note\n"), HasMeta: true}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid, "content must satisfy the selected schema") + require.Contains(t, err.Error(), "title") + + err = update(keg.NodeUpdateOptions{Schema: "note", Content: []byte("# Both\n"), HasContent: true, Meta: []byte("type: task\n"), HasMeta: true}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid, "meta must agree with the selected schema") + + require.NoError(t, update(keg.NodeUpdateOptions{Schema: "task", Content: []byte("# Both\n"), HasContent: true, Meta: []byte("type: task\n"), HasMeta: true})) + require.Equal(t, "task", typeOf()) + }) + + t.Run("preview agrees with the write", func(t *testing.T) { + result, err := k.ValidateNodePayload(agentCtx, keg.NodeValidationPayload{ID: created.ID, Content: []byte("# Preview\n"), HasContent: true}) + require.NoError(t, err) + require.True(t, result.Valid, "%+v", result.Issues) + }) + + t.Run("untyped node has no type to keep", func(t *testing.T) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = false })) + untyped, err := k.Create(agentCtx, &keg.CreateOptions{Body: []byte("# Untyped\n")}) + require.NoError(t, err) + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = true })) + view, err := k.ReadNode(ctx, untyped.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(agentCtx, []keg.NodeUpdateOptions{{ID: untyped.ID, Content: []byte("# Still untyped\n"), HasContent: true, ExpectedHash: view.Hash()}}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid) + require.Contains(t, err.Error(), "explicit schema selection is required") + }) + + t.Run("create with only meta.type needs a selection", func(t *testing.T) { + _, err := k.Create(agentCtx, &keg.CreateOptions{Body: []byte("# Declared\n"), Meta: []byte("type: note\n")}) + require.ErrorIs(t, err, keg.ErrSchemaInvalid) + require.Contains(t, err.Error(), "this keg defines: note, task") + require.NotContains(t, err.Error(), "keeps the node's type", "the edit hint is for updates only") + }) +} + func TestSchemaAwareDirectWritesPersistReplaceAndMutateAtomically(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) humanCtx := keg.WithValidationActor(ctx, keg.ValidationActorHuman) @@ -204,7 +294,9 @@ func TestSchemaAwareDirectWritesPersistReplaceAndMutateAtomically(t *testing.T) created, err := k.Create(humanCtx, &keg.CreateOptions{Schema: "note", Body: []byte("# Typed\n")}) require.NoError(t, err) - err = k.SetContent(humanCtx, created.ID, []byte("# Missing selection\n")) + // Content whose frontmatter declares another type changes the node's + // type, so it needs a selection even though metadata is untouched. + err = k.SetContent(humanCtx, created.ID, []byte("---\ntype: task\n---\n# Missing selection\n")) require.ErrorIs(t, err, keg.ErrSchemaInvalid) content, readErr := k.GetContent(ctx, created.ID) require.NoError(t, readErr) @@ -270,7 +362,7 @@ func TestValidateNodePayloadProjectsSchemaWithoutPersistence(t *testing.T) { payload keg.NodeValidationPayload want string }{ - {"missing", keg.NodeValidationPayload{ID: created.ID, Content: []byte("# Preview\n"), HasContent: true}, "explicit schema selection is required"}, + {"missing", keg.NodeValidationPayload{ID: created.ID, Meta: []byte("type: note\nowner: alice\n"), HasMeta: true}, "explicit schema selection is required"}, {"unknown", keg.NodeValidationPayload{ID: created.ID, Schema: "missing", Content: []byte("# Preview\n"), HasContent: true}, "unknown schema"}, {"conflict", keg.NodeValidationPayload{ID: created.ID, Schema: "task", Meta: []byte("type: note\n"), HasMeta: true}, "conflicts with metadata type"}, {"invalid", keg.NodeValidationPayload{ID: created.ID, Schema: "task", Content: []byte("\n"), HasContent: true}, "title"}, diff --git a/pkg/mcp/providers.go b/pkg/mcp/providers.go index b83561e..47f2ce9 100644 --- a/pkg/mcp/providers.go +++ b/pkg/mcp/providers.go @@ -12,6 +12,7 @@ import ( "github.com/jlrickert/tapper/pkg/apicontract" "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapapi" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -120,6 +121,21 @@ type NamespaceProvider interface { SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) } +// NodeSearchProvider ranks nodes by relevance across the KEGs a call may +// read. opts.Keg names one KEG; otherwise the search covers opts.Kegs, narrowed +// to the call's flight cover (opts.FlightContext), or with neither, every KEG +// the caller may read. The node_search tool has already run opts.Validate. +type NodeSearchProvider interface { + // SearchNodes returns one ranked page for opts.Query. + SearchNodes(ctx context.Context, opts tapper.SearchOptions) (*tapapi.SearchResponse, error) +} + +type localNodeSearchProvider struct{ tap *tapper.Tap } + +func (p localNodeSearchProvider) SearchNodes(ctx context.Context, opts tapper.SearchOptions) (*tapapi.SearchResponse, error) { + return p.tap.Search(ctx, opts) +} + // KegSearchRow is identity-authorized KEG metadata. Search results are not a // flight projection and never grant operational authority. type KegSearchRow struct { @@ -129,6 +145,7 @@ type KegSearchRow struct { Description string `json:"description"` Visibility string `json:"visibility"` Source string `json:"source"` + KegVersion string `json:"kegv,omitempty"` } // KegSearchResult includes partial-discovery warnings without failing useful @@ -642,6 +659,7 @@ func (p localKegDiscoveryProvider) CreateKeg(ctx context.Context, opts tapper.Cr Keg: opts.Keg, Title: opts.Title, Visibility: opts.Visibility, + KegVersion: opts.KegVersion, RequireBootstrap: true, }) if err != nil { @@ -685,6 +703,7 @@ func SearchIdentityKegsResult(rows []tapper.OrientationKeg, query string) KegSea matched = append(matched, KegSearchRow{ Ref: row.Ref, Role: tapper.EffectiveOrientationRole(row), Title: row.Title, Description: row.Description, Visibility: row.Visibility, Source: row.Source, + KegVersion: row.KegVersion, }) } sort.Slice(matched, func(i, j int) bool { return matched[i].Ref < matched[j].Ref }) diff --git a/pkg/mcp/public_response.go b/pkg/mcp/public_response.go index 39b6544..74b0f0b 100644 --- a/pkg/mcp/public_response.go +++ b/pkg/mcp/public_response.go @@ -82,7 +82,7 @@ func publicToolResponse(call *sdkmcp.CallToolResult) (*sdkmcp.CallToolResult, er } // Offset listings have no stable cursor or total in the service result. Do not -// invent a completion claim from rendered line counts (node_search spans many lines). +// invent a completion claim from rendered line counts (node_grep spans many lines). // A nonempty bounded page supplies a safe next request; an empty page ends it. func pagedLinesResult(lines []string, offset, limit int) *sdkmcp.CallToolResult { result := linesResult(lines) diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index eb5a175..eb65035 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -41,6 +41,10 @@ type ServerOptions struct { IdentityProvider IdentityProvider AgentProvider AgentProvider NamespaceProvider NamespaceProvider + // NodeSearchProvider runs ranked node search. The local default asks the + // selected Hub over REST; a Hub serving its own MCP endpoint injects one + // backed by its search service. + NodeSearchProvider NodeSearchProvider // SharedFilesystem reports that this server and the agent host driving it // see the same filesystem. That holds for stdio (`tap mcp`), where a path in // a tool argument names the same file on both sides, and never for a hosted @@ -84,6 +88,9 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se if opt.NamespaceProvider == nil { opt.NamespaceProvider = localNamespaceProvider{tap: tap} } + if opt.NodeSearchProvider == nil { + opt.NodeSearchProvider = localNodeSearchProvider{tap: tap} + } defaults.gate = newSessionFlightGate(opt.OrientationProvider) defaults.gate.clientVersion = version if opt.OrientationCacheTTL > 0 && tap != nil && tap.Runtime != nil { @@ -118,6 +125,7 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se // through Tap.resolveKegForRole, so a hub-injected KegResolver scopes them // to the caller's catalog with viewer/editor enforcement. registerReadTools(srv, tap, defaults) + registerNodeSearch(srv, defaults, opt.NodeSearchProvider) registerWriteTools(srv, tap, defaults) registerIndexTools(srv, tap, defaults) registerSnapshotTools(srv, tap, defaults) @@ -226,12 +234,12 @@ func mcpDefaultLimit(limit int) int { } // mcpDefaultMaxLinesValue is the default maximum number of matched lines per -// node returned by MCP node_search when the caller does not specify max_lines. CLI +// node returned by MCP node_grep when the caller does not specify max_lines. CLI // defaults to unlimited (0); MCP callers benefit from bounded output to // reduce token usage. const mcpDefaultMaxLinesValue = 3 -// mcpDefaultMaxLines resolves the max_lines value for MCP node_search. When the +// mcpDefaultMaxLines resolves the max_lines value for MCP node_grep. When the // caller omits max_lines (JSON zero value 0), the default of 3 is applied. // Passing -1 explicitly requests unlimited (converted to 0 for the Tap API). // Any positive value is passed through unchanged. @@ -260,6 +268,20 @@ func errorResult(err error) *sdkmcp.CallToolResult { errors.Is(err, keg.ErrOrientationUnavailable) || errors.Is(err, keg.ErrOrientationRootUnavailable) { return orientationFailureResult(err) } + var notApplied *tapper.KegVersionNotAppliedError + if errors.As(err, ¬Applied) { + action := "The KEG was created, at the version this error names rather than the requested one. Do not create it again: use it as that version, or delete it with keg_delete and ask the operator for a Hub that offers the requested version." + return &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: err.Error() + "\n\n" + action}}, + StructuredContent: map[string]any{ + "code": keg.RemoteCodeKegVersionUnsupported, + "operationPerformed": true, + "action": action, + "kegv": notApplied.Created, + }, + IsError: true, + } + } var conflict *keg.PreconditionConflictError if errors.As(err, &conflict) { structured := map[string]any{ @@ -330,11 +352,11 @@ func errorResult(err error) *sdkmcp.CallToolResult { func errorGuidance(code string) (action string, operationPerformed any) { switch code { case keg.RemoteCodeNotFound: - return "The target was not found or is not readable. Confirm its identity with `node_list`/`node_search` for nodes, `schema_list` for schemas, `flight_list`/`flight_search` for flights, or `keg_search` for KEGs before retrying.", false + return "The target was not found or is not readable. Confirm its identity with `node_list`/`node_search`/`node_grep` for nodes, `schema_list` for schemas, `flight_list`/`flight_search` for flights, or `keg_search` for KEGs before retrying.", false case keg.RemoteCodeExist, keg.RemoteCodeDestExists: return "Something already occupies that id or name. Choose another, or edit the existing node instead.", false case keg.RemoteCodeSchemaInvalid: - return "The node does not satisfy its schema. Read the issues in this error, fix `content` or `meta`, and retry. `schema_read` shows the schema's requirements.", false + return "If the error says a schema selection is required, pass `schema` with one of the types it lists (`schema_list` lists them) and retry; type in `meta` alone does not count. Otherwise the node does not satisfy its schema: read the issues in this error, fix `content` or `meta`, and retry. `schema_read` shows the schema's requirements.", false case keg.RemoteCodeInvalid, keg.RemoteCodeBadRequest: return "The request is malformed. Fix the field named in the error and retry; do not retry the same arguments.", false case keg.RemoteCodeInvalidImage: @@ -347,6 +369,8 @@ func errorGuidance(code string) (action string, operationPerformed any) { return "The lock could not be acquired in time, usually because another write is in flight. Retry shortly; nothing was written.", false case keg.RemoteCodeNotSupported: return "This backend does not implement the operation. Do not retry unchanged; use a supported tool from tools/list or have the operator update the backend.", false + case keg.RemoteCodeKegVersionUnsupported: + return "The KEG version does not offer the operation, or the Hub does not offer the requested KEG version. Do not retry unchanged: for keg_create, omit kegv to create a KEGv1 KEG; for node tools, keg_list and keg_search label KEGv2 preview KEGs, so pick a KEGv1 KEG.", false default: return "The outcome is unknown: this failure may follow a partial write. Inspect the resource before retrying: `node_read` for nodes, `schema_read` for schemas, `keg_settings_read` with minimal=false for settings, or `flight_read` for flights. Discover possible creations before repeating them; do not blindly replay a mutation.", nil } diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index be8496c..abdea00 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -240,7 +240,7 @@ func TestMCP_ToolsList(t *testing.T) { names[tool.Name] = true } for _, want := range []string{ - "session_info", "keg_list", "keg_search", "node_read", "node_list", "node_search", "tag_list", "node_backlinks", "node_links", "keg_info", + "session_info", "keg_list", "keg_search", "node_read", "node_list", "node_search", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", "keg_settings_read", "keg_settings_edit", "node_stats", "node_create", "node_edit", "node_delete", "node_move", "index_rebuild", "index_list", "index_read", "keg_check", "snapshot_list", "snapshot_create", "snapshot_read", "snapshot_restore", "file_list", "image_list", "file_delete", "image_delete", @@ -284,7 +284,7 @@ func TestMCP_CommonAgentSafeSurface(t *testing.T) { // Common KEG and account tools must be present. for _, want := range []string{ - "node_read", "node_list", "node_search", "tag_list", "node_backlinks", "node_links", "keg_info", "keg_settings_read", + "node_read", "node_list", "node_search", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", "keg_settings_read", "keg_settings_edit", "node_stats", "node_create", "node_edit", "node_delete", "node_move", "index_rebuild", "index_list", "index_read", "snapshot_list", "snapshot_create", @@ -490,7 +490,7 @@ func TestMCP_Grep(t *testing.T) { session, ctx := newTestSession(t) res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "node_search", + Name: "node_grep", Arguments: map[string]any{ "query": "Hello", }, @@ -2139,7 +2139,7 @@ func TestMCP_ToolAnnotations_AllPresent(t *testing.T) { // --- read-only tools --- readOnlyTools := []string{ - "node_read", "node_list", "node_search", "tag_list", "node_backlinks", "node_links", + "node_read", "node_list", "node_search", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", "keg_settings_read", "node_stats", "file_list", "image_list", "index_list", "index_read", diff --git a/pkg/mcp/tools_format_schema_test.go b/pkg/mcp/tools_format_schema_test.go index 8fbe198..a0ee557 100644 --- a/pkg/mcp/tools_format_schema_test.go +++ b/pkg/mcp/tools_format_schema_test.go @@ -37,7 +37,7 @@ func schemaProperties(t *testing.T, schema any) map[string]string { // formatToolNames are the tools whose output is rendered through the shared // listing formatter, and which therefore must advertise the shared vocabulary. -var formatToolNames = []string{"node_list", "node_search", "tag_list", "node_backlinks", "node_links"} +var formatToolNames = []string{"node_list", "node_grep", "tag_list", "node_backlinks", "node_links"} // TestMCP_FormatSchemaAdvertisesSharedVocabulary pins the generated tool // schemas to the vocabulary the formatter actually implements. diff --git a/pkg/mcp/tools_keg.go b/pkg/mcp/tools_keg.go index 6876867..331a8e8 100644 --- a/pkg/mcp/tools_keg.go +++ b/pkg/mcp/tools_keg.go @@ -15,9 +15,10 @@ import ( type kegListInput struct{} type kegListRow struct { - Ref string `json:"ref"` - Role string `json:"role"` - Flights []string `json:"flights"` + Ref string `json:"ref"` + Role string `json:"role"` + Flights []string `json:"flights"` + KegVersion string `json:"kegv,omitempty"` } type kegListOutput struct { @@ -32,6 +33,7 @@ type kegCreateInput struct { Keg string `json:"keg" jsonschema:"required,qualified creation reference @namespace/keg; each component is 1-64 lowercase letters, digits, or hyphens and starts with a letter or digit"` Title string `json:"title,omitempty" jsonschema:"human-readable keg title"` Visibility string `json:"visibility,omitempty" jsonschema:"keg visibility: private (default) or public"` + KegVersion string `json:"kegv,omitempty" jsonschema:"immutable keg version: 2025-07 for a Markdown KEGv1 (the default) or 2026-09 for a KEGv2 preview, available only where the Hub enables it"` } // registerKegTools exposes identity-authorized discovery, optionally narrowed @@ -56,8 +58,12 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover for _, row := range rows { effective := EffectiveOrientationRole(row) flights := append([]string{}, row.Flights...) - out.Kegs = append(out.Kegs, kegListRow{Ref: row.Ref, Role: effective, Flights: flights}) - lines = append(lines, row.Ref+"\t"+effective+"\t"+strings.Join(flights, ",")) + out.Kegs = append(out.Kegs, kegListRow{Ref: row.Ref, Role: effective, Flights: flights, KegVersion: row.KegVersion}) + line := row.Ref + "\t" + effective + "\t" + strings.Join(flights, ",") + if label := tapper.KegVersionLabel(row.KegVersion); label != "" { + line += "\t" + label + } + lines = append(lines, line) } res := linesResult(lines) res.StructuredContent = out @@ -106,7 +112,7 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover } for _, row := range found.Kegs { lines = append(lines, strings.Join([]string{ - row.Ref, row.Role, tsvField(row.Title), tsvField(row.Description), row.Visibility, row.Source, + row.Ref, row.Role, tsvField(tapper.KegTitleWithVersion(row.Title, row.KegVersion)), tsvField(row.Description), row.Visibility, row.Source, }, "\t")) } res := linesResult(lines) @@ -130,10 +136,15 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover if err := defaults.gate.authorizeKegCreation(ctx); err != nil { return errorResult(err), nil, nil } + version, err := tapper.NormalizeKegVersion(in.KegVersion) + if err != nil { + return errorResult(err), nil, nil + } ref, err := kegs.CreateKeg(ctx, tapper.CreateKegOptions{ Keg: in.Keg, Title: in.Title, Visibility: in.Visibility, + KegVersion: version, }) if err != nil { return errorResult(err), nil, nil diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index 65d2cae..1cf8f1f 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -162,7 +162,7 @@ func registerList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { }) } -// --- node_search --- +// --- node_grep --- type grepInput struct { Query string `json:"query" jsonschema:"regex pattern to search node content, matched per line. Local KEGs use Go RE2 syntax; Hub KEGs use PostgreSQL regex, where \\y (not \\b) is a word boundary and lookahead is allowed"` @@ -178,8 +178,8 @@ type grepInput struct { func registerGrep(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "node_search", - Description: "Search KEG node content with a regex pattern. max_lines defaults to 3 matched lines per node; -1 returns all matched lines. Follow next_offset for more nodes; use node_read for complete bodies.", + Name: "node_grep", + Description: "Match KEG node content lines against a regex pattern in one KEG, in node id order. Use it for exact identifiers, symbols, and audits; use node_search to find the nodes about a topic. max_lines defaults to 3 matched lines per node; -1 returns all matched lines. Follow next_offset for more nodes; use node_read for complete bodies.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_search.go b/pkg/mcp/tools_search.go new file mode 100644 index 0000000..a63255f --- /dev/null +++ b/pkg/mcp/tools_search.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "context" + "strings" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/tapapi" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- node_search --- + +type nodeSearchInput struct { + Query string `json:"query" jsonschema:"required,what to find: plain terms (any may match; more matches and title or metadata matches rank higher), \"quoted phrases\" that must appear, and -exclusions"` + Keg string `json:"keg,omitempty" jsonschema:"search only this KEG (@namespace/keg or alias). Omit to search every KEG this call may read; never combined with kegs"` + Kegs []string `json:"kegs,omitempty" jsonschema:"narrow the search to these @namespace/keg references (at most 50); omit to search every KEG this call may read"` + Cursor string `json:"cursor,omitempty" jsonschema:"next_cursor from a previous node_search response for the same query, passed back unchanged"` + Limit int `json:"limit,omitempty" jsonschema:"maximum hits to return, 0 or more; omit it or pass 0 to let the Hub choose its default. Unlike listing tools, -1 (unlimited) is not accepted"` +} + +type nodeSearchOutput struct { + tapapi.SearchResponse + Pagination string `json:"pagination"` +} + +func registerNodeSearch(srv *sdkmcp.Server, defaults KegDefaults, provider NodeSearchProvider) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "node_search", + Description: "Find the KEG nodes most relevant to a query, ranked across every KEG this call may read, one named keg, or the listed kegs. " + + "Matches titles, metadata, tags, and bodies. Use node_grep for exact regex matching of identifiers or symbols. " + + "Each hit gives the node reference, score, title, matched fields, and a snippet with matches in **bold**; " + + "follow next_cursor for more and use node_read for complete bodies.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in nodeSearchInput) (*sdkmcp.CallToolResult, any, error) { + target := resolveKegTarget(ctx, in.Keg, defaults) + // An omitted keg means the whole readable scope, never the server's + // default KEG. + target.Keg = strings.TrimSpace(in.Keg) + opts := tapper.SearchOptions{ + KegTargetOptions: target, + Kegs: in.Kegs, + Query: in.Query, + Cursor: in.Cursor, + Limit: in.Limit, + } + if _, err := opts.Validate(); err != nil { + return errorResult(err), nil, nil + } + var resp *tapapi.SearchResponse + if sessionReachesNothing(ctx) { + // Failed-root recovery has no flight snapshot yet reaches no KEG; an + // unscoped request would ask the Hub for everything. + resp = &tapapi.SearchResponse{Hits: []tapapi.SearchHit{}} + } else { + var err error + resp, err = provider.SearchNodes(ctx, opts) + if err != nil { + return errorResult(err), nil, nil + } + } + if resp.Hits == nil { + resp.Hits = []tapapi.SearchHit{} + } + res := linesResult(nodeSearchLines(resp)) + pagination := "This is the last page." + if resp.NextCursor != "" { + pagination = "Repeat the same query with next_cursor as cursor for the next page." + } + res.StructuredContent = nodeSearchOutput{SearchResponse: *resp, Pagination: pagination} + return res, nil, nil + }) +} + +// sessionReachesNothing reports the governed state with no flight snapshot +// and no full access: failed-root recovery, which may reach no KEG. +func sessionReachesNothing(ctx context.Context) bool { + return HasSessionOrientation(ctx) && SessionFlight(ctx) == nil && !SessionFullAccess(ctx) +} + +// nodeSearchLines renders warnings, one line per hit, then the next page's +// cursor when there is one. +func nodeSearchLines(resp *tapapi.SearchResponse) []string { + lines := make([]string, 0, len(resp.Warnings)+len(resp.Hits)+1) + for _, warning := range resp.Warnings { + lines = append(lines, "Warning: "+tsvField(warning)) + } + lines = append(lines, tapper.SearchHitLines(resp.Hits, "**", "**")...) + if len(resp.Hits) == 0 { + lines = append(lines, "No matching nodes.") + } + if resp.NextCursor != "" { + // Text-only clients page from this line; structured clients read + // next_cursor. + lines = append(lines, tapper.SearchNextCursorLine(resp.NextCursor)) + } + return lines +} diff --git a/pkg/mcp/tools_search_test.go b/pkg/mcp/tools_search_test.go new file mode 100644 index 0000000..6f044db --- /dev/null +++ b/pkg/mcp/tools_search_test.go @@ -0,0 +1,186 @@ +package mcp_test + +import ( + "context" + "sync" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapapi" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// recordingNodeSearch is a hosted NodeSearchProvider that records each call. +type recordingNodeSearch struct { + mu sync.Mutex + calls []tapper.SearchOptions + resp *tapapi.SearchResponse + err error +} + +func (r *recordingNodeSearch) SearchNodes(_ context.Context, opts tapper.SearchOptions) (*tapapi.SearchResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, opts) + return r.resp, r.err +} + +func (r *recordingNodeSearch) recorded() []tapper.SearchOptions { + r.mu.Lock() + defer r.mu.Unlock() + return append([]tapper.SearchOptions(nil), r.calls...) +} + +func callNodeSearch(t *testing.T, session *sdkmcp.ClientSession, ctx context.Context, args map[string]any) *sdkmcp.CallToolResult { + t.Helper() + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "node_search", Arguments: args}) + require.NoError(t, err) + return res +} + +func TestMCP_NodeSearchUsesTheInjectedProvider(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{resp: &tapapi.SearchResponse{ + Hits: []tapapi.SearchHit{{ + Ref: "keg:@acme/notes/12", Keg: "@acme/notes", NodeID: "12", Title: "Festival trip", Score: 4.25, + Fields: []string{tapapi.SearchFieldTitle}, Sources: []string{tapapi.SearchSourceSemantic}, + Snippet: "festival in Los Angeles", Highlights: []tapapi.Span{{Start: 0, End: 8}}, + }}, + NextCursor: "c2", Mode: tapapi.SearchModeHybrid, Warnings: []string{"partial"}, + }} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{NodeSearchProvider: provider}) + + res := callNodeSearch(t, session, ctx, map[string]any{ + "query": "festival", "kegs": []string{"@acme/notes"}, "cursor": " c1 ", "limit": 7, + }) + require.False(t, res.IsError, "node_search failed: %v", extractText(t, res)) + + calls := provider.recorded() + require.Len(t, calls, 1) + require.Equal(t, "festival", calls[0].Query) + require.Equal(t, []string{"@acme/notes"}, calls[0].Kegs) + require.Equal(t, " c1 ", calls[0].Cursor, "the cursor is opaque and passed through unchanged") + require.Equal(t, 7, calls[0].Limit) + require.Empty(t, calls[0].Keg, "an omitted keg searches the readable scope, not the default KEG") + require.False(t, calls[0].BypassFlightRestrictions, "MCP searches stay inside the flight's cover") + + text := extractText(t, res) + require.Contains(t, text, "Warning: partial") + require.Contains(t, text, "keg:@acme/notes/12\t4.250\tFestival trip\t**festival** in Los Angeles") + require.Contains(t, text, "next cursor: c2", "text-only clients need the cursor to page") + + payload, ok := res.StructuredContent.(map[string]any) + require.True(t, ok) + require.Equal(t, "c2", payload["next_cursor"]) + require.Equal(t, tapapi.SearchModeHybrid, payload["mode"]) + require.Contains(t, payload["pagination"], "next_cursor") + hits, ok := payload["hits"].([]any) + require.True(t, ok) + require.Len(t, hits, 1) + hit := hits[0].(map[string]any) + require.Equal(t, []any{tapapi.SearchSourceSemantic}, hit["sources"]) +} + +func TestMCP_NodeSearchOmittedLimitStaysZero(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{resp: &tapapi.SearchResponse{}} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{NodeSearchProvider: provider}) + + res := callNodeSearch(t, session, ctx, map[string]any{"query": "festival", "keg": "@acme/notes"}) + require.False(t, res.IsError, "node_search failed: %v", extractText(t, res)) + calls := provider.recorded() + require.Len(t, calls, 1) + require.Zero(t, calls[0].Limit, "the Hub owns the default page size") + require.Equal(t, "@acme/notes", calls[0].Keg) + + payload := res.StructuredContent.(map[string]any) + require.Equal(t, []any{}, payload["hits"], "a nil hit list renders as an empty array") + require.Contains(t, extractText(t, res), "No matching nodes.") + require.NotContains(t, extractText(t, res), "next cursor", "the last page names no cursor") + require.Equal(t, "This is the last page.", payload["pagination"]) +} + +func TestMCP_NodeSearchRefusesInvalidInputBeforeTheProvider(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{resp: &tapapi.SearchResponse{}} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{NodeSearchProvider: provider}) + + for name, args := range map[string]map[string]any{ + "empty query": {"query": " "}, + "negative limit": {"query": "x", "limit": -1}, + "bare kegs ref": {"query": "x", "kegs": []string{"notes"}}, + "keg and kegs": {"query": "x", "keg": "@acme/notes", "kegs": []string{"@acme/eng"}}, + } { + res := callNodeSearch(t, session, ctx, args) + require.True(t, res.IsError, name) + require.Equal(t, keg.RemoteCodeInvalid, res.StructuredContent.(map[string]any)["code"], name) + } + require.Empty(t, provider.recorded()) +} + +func TestMCP_NodeSearchUnavailableNamesNodeGrep(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{err: tapper.ErrRankedSearchUnavailable} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{NodeSearchProvider: provider}) + + res := callNodeSearch(t, session, ctx, map[string]any{"query": "x"}) + require.True(t, res.IsError) + require.Contains(t, extractText(t, res), "ranked search unavailable") + payload := res.StructuredContent.(map[string]any) + require.Equal(t, keg.RemoteCodeNotSupported, payload["code"], "a missing capability is not an unknown failure") + require.Equal(t, false, payload["operationPerformed"], "a search never writes") + require.Contains(t, payload["action"], "supported tool") +} + +func TestMCP_NodeGrepKeepsRegexBehavior(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "node_grep", Arguments: map[string]any{"query": "Hel+o", "id_only": true}}) + require.NoError(t, err) + require.False(t, res.IsError, "node_grep failed: %v", extractText(t, res)) + require.NotEmpty(t, extractText(t, res)) +} + +func newFlightSearchSession(t *testing.T, mode string, provider mcp.NodeSearchProvider) (*sdkmcp.ClientSession, context.Context) { + t.Helper() + backend := newRefreshFlightBackend(mode) + ctx := context.Background() + sandbox := newTestSandbox(t) + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sandbox.Runtime()}) + require.NoError(t, err) + server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ + OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, + KegSearchProvider: backend, IdentityProvider: backend, NodeSearchProvider: provider, + }) + return connectFlightSession(t, ctx, server, nil), ctx +} + +func TestMCP_NodeSearchCarriesTheCallFlight(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{resp: &tapapi.SearchResponse{}} + session, ctx := newFlightSearchSession(t, "active", provider) + + res := callNodeSearch(t, session, ctx, map[string]any{"query": "release"}) + require.False(t, res.IsError, "node_search failed: %v", extractText(t, res)) + calls := provider.recorded() + require.Len(t, calls, 1) + require.NotNil(t, calls[0].FlightContext, "the provider must see the flight it searches within") + require.Equal(t, "@team/+root", calls[0].FlightContext.Name) +} + +func TestMCP_NodeSearchReachesNothingWithoutAuthority(t *testing.T) { + t.Parallel() + provider := &recordingNodeSearch{resp: &tapapi.SearchResponse{Hits: []tapapi.SearchHit{{Ref: "keg:@team/x/1"}}}} + session, ctx := newFlightSearchSession(t, "failed", provider) + + res := callNodeSearch(t, session, ctx, map[string]any{"query": "release"}) + if !res.IsError { + require.Equal(t, []any{}, res.StructuredContent.(map[string]any)["hits"]) + } + require.Empty(t, provider.recorded(), "a session that reaches no KEG must not search the Hub") +} diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index 040fa5c..d95299e 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -80,7 +80,7 @@ type createNodeInput struct { Key string `json:"key" jsonschema:"caller-chosen label for this node within the batch, unique across the batch. Other nodes in the same batch link to it by writing {{node:KEY}} in their content; each placeholder is replaced with the id this node is assigned."` Content string `json:"content" jsonschema:"the node's complete markdown body, opening with an H1 title. Must not start with a YAML frontmatter block: metadata goes in the meta field."` Meta string `json:"meta,omitempty" jsonschema:"the node's complete metadata document as YAML, including keys such as type, tags, and any schema-defined attributes. Omit for no metadata."` - Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write. Writes meta.type; a different type declared in meta is a hard error, not an override. Required when strict policy and agent mode both block."` + Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write, one of the types schema_list returns. Writes meta.type; a different type declared in meta is a hard error, not an override. Required on a strict keg (the default for new kegs), where type in meta alone does not count as a selection."` } type createNodeOutput struct { @@ -106,7 +106,7 @@ func registerCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { "content must not begin with a YAML frontmatter block — metadata goes in meta. " + "A node's title is its content H1 and its lead is the first paragraph, so there are no separate title, lead, tags, or attrs fields. " + "Nodes in one batch can reference each other's not-yet-assigned ids by writing {{node:KEY}} in content. " + - "A schema selection is required only when strict policy and the resolved agent mode both block.", + "On a strict keg (the default for new kegs) every node needs schema, and type in meta alone does not count; call schema_list once per keg before the first write to learn its types.", InputSchema: boundedMutationInputSchema[createInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), @@ -144,7 +144,7 @@ type editItemInput struct { Meta *string `json:"meta,omitempty" jsonschema:"replacement metadata document as YAML, replacing the node's metadata entirely. Omit to leave metadata unchanged."` ExpectedContentHash string `json:"expected_content_hash,omitempty" jsonschema:"content_hash from node_read (or from a previous node_edit result); required when content is supplied"` ExpectedMetaHash string `json:"expected_meta_hash,omitempty" jsonschema:"meta_hash from node_read (or from a previous node_edit result); required when meta is supplied"` - Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write. Writes meta.type; a different type declared in meta is a hard error, not an override. Required when strict policy and agent mode both block."` + Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write, one of the types schema_list returns. Writes meta.type; a different type declared in meta is a hard error, not an override. On a strict keg (the default for new kegs) required whenever meta is supplied; a content-only edit keeps the node's current type and needs none."` } // nodeUpdateOutput carries the written node's tokens. content_hash and @@ -177,7 +177,7 @@ func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { "Call node_read first for every node; node_read meta_only reads metadata. " + "Each result returns the node's new content_hash and meta_hash, so a follow-up edit needs no re-read; its combined hash (for node_delete or node_move) changes with every edit. " + "Take a snapshot with snapshot_create before a large or destructive edit. " + - "A schema selection is required only when strict policy and the resolved agent mode both block. " + + "On a strict keg (the default for new kegs) an edit that supplies meta needs schema; a content-only edit keeps the node's current type and is validated against it. " + "On conflict, merge into the returned current content (or refetch with node_read) and retry with the returned current hash for the part you are writing: currentContentHash as expected_content_hash, currentMetaHash as expected_meta_hash.", InputSchema: boundedMutationInputSchema[editInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 0e1559b..e7e91f8 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -25,7 +25,8 @@ var tapMethodToSurfaces = map[string]struct { // `_`, so each pair names the same thing. "Cat": {CLI: "node read", MCP: []string{"node_read"}}, "List": {CLI: "node list", MCP: []string{"node_list"}}, - "Grep": {CLI: "node search", MCP: []string{"node_search"}}, + "Search": {CLI: "node search", MCP: []string{"node_search"}}, + "Grep": {CLI: "node grep", MCP: []string{"node_grep"}}, "Tags": {CLI: "tag list", MCP: []string{"tag_list"}}, "Backlinks": {CLI: "node backlinks", MCP: []string{"node_backlinks"}}, "Links": {CLI: "node links", MCP: []string{"node_links"}}, diff --git a/pkg/parity/parity_keg_create_test.go b/pkg/parity/parity_keg_create_test.go index 841b3c7..6f67282 100644 --- a/pkg/parity/parity_keg_create_test.go +++ b/pkg/parity/parity_keg_create_test.go @@ -63,3 +63,87 @@ func TestParity_KegCreateQualifiedReference(t *testing.T) { defer mu.Unlock() require.Equal(t, []string{"/api/v1/@acme/kegs/engineering", "/api/v1/@acme/kegs/engineering"}, destinations) } + +func TestParity_KegCreateVersion(t *testing.T) { + e := newParityEnv(t) + var mu sync.Mutex + var versions []string + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + mu.Lock() + versions = append(versions, body["alias"]+"="+body["kegv"]) + mu.Unlock() + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"namespace": "acme", "alias": body["alias"], "kegv": body["kegv"]}) + })) + defer srv.Close() + e.sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: home\nhubs:\n home:\n url: %s\n token: test-token\n", srv.URL)), 0644) + e.tap.ConfigService.Reload() + + _, err := e.runCLI("keg", "create", "@acme/cli-graph", "--kegv", "2026-09") + require.NoError(t, err) + _, err = e.runMCP("keg_create", map[string]any{"keg": "@acme/mcp-graph", "kegv": "2026-09"}) + require.NoError(t, err) + _, err = e.runCLI("keg", "create", "@acme/cli-notes") + require.NoError(t, err) + _, err = e.runMCP("keg_create", map[string]any{"keg": "@acme/mcp-notes"}) + require.NoError(t, err) + + _, cliErr := e.runCLI("keg", "create", "@acme/bad", "--kegv", "2027-01") + _, mcpErr := e.runMCP("keg_create", map[string]any{"keg": "@acme/bad", "kegv": "2027-01"}) + require.ErrorContains(t, cliErr, "keg version") + require.ErrorContains(t, mcpErr, "keg version") + + mu.Lock() + defer mu.Unlock() + require.Equal(t, []string{"cli-graph=2026-09", "mcp-graph=2026-09", "cli-notes=", "mcp-notes="}, versions) +} + +// TestParity_KegCreateVersionUnsupported checks that a Hub refusing the +// requested KEG version is reported as that, not as an existing KEG, on both +// surfaces. +func TestParity_KegCreateVersionUnsupported(t *testing.T) { + e := newParityEnv(t) + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "KEG version 2026-09 is not enabled on this hub", "code": "KEG_VERSION_UNSUPPORTED", + }) + })) + defer srv.Close() + e.sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: home\nhubs:\n home:\n url: %s\n token: test-token\n", srv.URL)), 0644) + e.tap.ConfigService.Reload() + + _, cliErr := e.runCLI("keg", "create", "@acme/graph", "--kegv", "2026-09") + _, mcpErr := e.runMCP("keg_create", map[string]any{"keg": "@acme/graph", "kegv": "2026-09"}) + for name, err := range map[string]error{"cli": cliErr, "mcp": mcpErr} { + require.ErrorContains(t, err, "is not enabled on this hub", name) + require.NotContains(t, err.Error(), "already exists", name) + } + require.ErrorContains(t, mcpErr, "omit kegv", "MCP guidance must point at creating a KEGv1 KEG") +} + +// TestParity_KegCreateVersionNotApplied checks that both surfaces report a +// KEG a Hub created as KEGv1 because it ignored the requested version. +func TestParity_KegCreateVersionNotApplied(t *testing.T) { + e := newParityEnv(t) + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + // A Hub that predates versioned KEGs echoes no kegv. + _ = json.NewEncoder(w).Encode(map[string]string{"namespace": "acme", "alias": body["alias"]}) + })) + defer srv.Close() + e.sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: home\nhubs:\n home:\n url: %s\n token: test-token\n", srv.URL)), 0644) + e.tap.ConfigService.Reload() + + _, cliErr := e.runCLI("keg", "create", "@acme/cli-graph", "--kegv", "2026-09") + _, mcpErr := e.runMCP("keg_create", map[string]any{"keg": "@acme/mcp-graph", "kegv": "2026-09"}) + require.ErrorContains(t, cliErr, "created @acme/cli-graph as KEGv1 (2025-07)") + require.ErrorContains(t, mcpErr, "created @acme/mcp-graph as KEGv1 (2025-07)") + require.ErrorContains(t, mcpErr, "Do not create it again") +} diff --git a/pkg/parity/parity_read_test.go b/pkg/parity/parity_read_test.go index 374d285..3e703d9 100644 --- a/pkg/parity/parity_read_test.go +++ b/pkg/parity/parity_read_test.go @@ -14,7 +14,8 @@ import ( // // tap node read -> cat (both call Tap.Cat) // tap node list -> list (both call Tap.List) -// tap node search -> grep (both call Tap.Grep) +// tap node grep -> grep (both call Tap.Grep) +// tap node search -> node_search (both call Tap.Search; see TestParity_NodeSearchRequest) // tap tag list -> tags (both call Tap.Tags) // tap node backlinks -> backlinks (both call Tap.Backlinks) // tap node links -> links (both call Tap.Links) @@ -267,8 +268,8 @@ func TestParity_ReadOperations(t *testing.T) { // --- grep (Tap.Grep) --- { Name: "grep/id_only", - CLIArgs: []string{"node", "search", "Hello", "--id-only"}, - MCPTool: "node_search", + CLIArgs: []string{"node", "grep", "Hello", "--id-only"}, + MCPTool: "node_grep", MCPInput: map[string]any{ "query": "Hello", "id_only": true, @@ -277,8 +278,8 @@ func TestParity_ReadOperations(t *testing.T) { }, { Name: "grep/default_format", - CLIArgs: []string{"node", "search", "Hello"}, - MCPTool: "node_search", + CLIArgs: []string{"node", "grep", "Hello"}, + MCPTool: "node_grep", MCPInput: map[string]any{ "query": "Hello", "limit": -1, @@ -286,8 +287,8 @@ func TestParity_ReadOperations(t *testing.T) { }, { Name: "grep/no_match_returns_empty", - CLIArgs: []string{"node", "search", "ZZZZNOTFOUND", "--id-only"}, - MCPTool: "node_search", + CLIArgs: []string{"node", "grep", "ZZZZNOTFOUND", "--id-only"}, + MCPTool: "node_grep", MCPInput: map[string]any{ "query": "ZZZZNOTFOUND", "id_only": true, @@ -395,8 +396,8 @@ func TestParity_ReadOperations(t *testing.T) { // --- grep with offset (Tap.Grep) --- { Name: "grep/offset_skips_results", - CLIArgs: []string{"node", "search", "node", "--id-only", "--ignore-case", "--offset", "1"}, - MCPTool: "node_search", + CLIArgs: []string{"node", "grep", "node", "--id-only", "--ignore-case", "--offset", "1"}, + MCPTool: "node_grep", MCPInput: map[string]any{ "query": "node", "id_only": true, @@ -540,8 +541,8 @@ func TestParity_ReadOperations(t *testing.T) { // resolves to 3 for MCP) should produce the same output. { Name: "grep/max_lines_default_parity", - CLIArgs: []string{"node", "search", "node", "--max-lines", "3", "--ignore-case"}, - MCPTool: "node_search", + CLIArgs: []string{"node", "grep", "node", "--max-lines", "3", "--ignore-case"}, + MCPTool: "node_grep", MCPInput: map[string]any{ "query": "node", "ignore_case": true, diff --git a/pkg/parity/parity_search_test.go b/pkg/parity/parity_search_test.go new file mode 100644 index 0000000..a4e49e9 --- /dev/null +++ b/pkg/parity/parity_search_test.go @@ -0,0 +1,154 @@ +package parity_test + +import ( + "fmt" + "io" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapapi" +) + +// searchHubReply is what the fake Hub answers every ranked search with. +const searchHubReply = `{"hits":[{"ref":"keg:@acme/notes/12","keg":"@acme/notes","node_id":"12","title":"Festival trip","score":4.25,` + + `"fields":["title"],"snippet":"festival in Los Angeles","highlights":[{"start":0,"end":8}]}],"mode":"keyword"}` + +// newSearchParityEnv points the parity environment's Hub at a fake that +// records each ranked-search request body verbatim. +func newSearchParityEnv(t *testing.T) (*parityEnv, func() []string) { + t.Helper() + e := newParityEnv(t) + var mu sync.Mutex + var bodies []string + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1"+tapapi.PathSearch { + http.NotFound(w, r) + return + } + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + mu.Lock() + bodies = append(bodies, string(raw)) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Header().Set(tapapi.SearchRouteHeader, tapapi.SearchRouteHeaderValue) + _, _ = w.Write([]byte(searchHubReply)) + })) + t.Cleanup(srv.Close) + e.sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: home\nhubs:\n home:\n url: %s\n token: test-token\n", srv.URL)), 0644) + e.tap.ConfigService.Reload() + return e, func() []string { + mu.Lock() + defer mu.Unlock() + out := append([]string(nil), bodies...) + bodies = nil + return out + } +} + +// TestParity_NodeSearchRequest checks, parameter by parameter, that `tap node +// search` and node_search send the Hub the same SearchRequest and render the +// same hit lines. +func TestParity_NodeSearchRequest(t *testing.T) { + e, drain := newSearchParityEnv(t) + + cases := []struct { + name string + cli []string + mcp map[string]any + want string + }{ + { + name: "query only omits limit, cursor, and kegs", + cli: []string{"node", "search", "festival"}, + mcp: map[string]any{"query": "festival"}, + want: `{"query":"festival"}`, + }, + { + name: "multi-word query", + cli: []string{"node", "search", "--", "los", `"angeles trip"`, "-draft"}, + mcp: map[string]any{"query": `los "angeles trip" -draft`}, + want: `{"query":"los \"angeles trip\" -draft"}`, + }, + { + name: "kegs", + cli: []string{"node", "search", "festival", "--kegs", "@acme/notes,@acme/eng"}, + mcp: map[string]any{"query": "festival", "kegs": []string{"@acme/notes", "@acme/eng"}}, + want: `{"query":"festival","kegs":["@acme/notes","@acme/eng"]}`, + }, + { + name: "one keg", + cli: []string{"node", "search", "festival", "--keg", "@acme/notes"}, + mcp: map[string]any{"query": "festival", "keg": "@acme/notes"}, + want: `{"query":"festival","kegs":["@acme/notes"]}`, + }, + { + name: "cursor passes through unchanged", + cli: []string{"node", "search", "festival", "--cursor", "opaque/c+2=="}, + mcp: map[string]any{"query": "festival", "cursor": "opaque/c+2=="}, + want: `{"query":"festival","cursor":"opaque/c+2=="}`, + }, + { + name: "limit", + cli: []string{"node", "search", "festival", "--limit", "5"}, + mcp: map[string]any{"query": "festival", "limit": 5}, + want: `{"query":"festival","limit":5}`, + }, + } + for _, tc := range cases { + cliOut, err := e.runCLI(tc.cli...) + require.NoError(t, err, tc.name) + mcpOut, err := e.runMCP("node_search", tc.mcp) + require.NoError(t, err, tc.name) + + bodies := drain() + require.Len(t, bodies, 2, tc.name) + require.JSONEq(t, tc.want, bodies[0], "%s: CLI request", tc.name) + require.Equal(t, bodies[0], bodies[1], "%s: CLI and MCP must send the same SearchRequest", tc.name) + + require.Equal(t, "keg:@acme/notes/12\t4.250\tFestival trip\t**festival** in Los Angeles", cliOut, tc.name) + require.Equal(t, cliOut, mcpOut, "%s: CLI and MCP must render hits the same way", tc.name) + } +} + +// TestParity_NodeSearchRejectsTheSameInput checks that both surfaces refuse +// the same invalid requests without contacting the Hub. +func TestParity_NodeSearchRejectsTheSameInput(t *testing.T) { + e, drain := newSearchParityEnv(t) + + cases := []struct { + name string + cli []string + mcp map[string]any + }{ + {"negative limit", []string{"node", "search", "x", "--limit", "-1"}, map[string]any{"query": "x", "limit": -1}}, + {"bare keg ref", []string{"node", "search", "x", "--kegs", "notes"}, map[string]any{"query": "x", "kegs": []string{"notes"}}}, + {"keg and kegs", []string{"node", "search", "x", "--keg", "@acme/a", "--kegs", "@acme/b"}, map[string]any{"query": "x", "keg": "@acme/a", "kegs": []string{"@acme/b"}}}, + } + for _, tc := range cases { + _, cliErr := e.runCLI(tc.cli...) + _, mcpErr := e.runMCP("node_search", tc.mcp) + require.Error(t, cliErr, tc.name) + require.Error(t, mcpErr, tc.name) + } + require.Empty(t, drain(), "invalid searches must not reach the Hub") +} + +// TestParity_NodeSearchWithoutLoginNeedsLogin checks that both surfaces report +// a Hub without a credential as an authentication problem. +func TestParity_NodeSearchWithoutLoginNeedsLogin(t *testing.T) { + e, drain := newSearchParityEnv(t) + e.sb.MustWriteFile("~/.config/tapper/config.yaml", []byte("hub: home\nhubs:\n home:\n url: https://hub.example.invalid\n"), 0644) + e.tap.ConfigService.Reload() + + _, cliErr := e.runCLI("node", "search", "festival") + _, mcpErr := e.runMCP("node_search", map[string]any{"query": "festival"}) + require.ErrorContains(t, cliErr, "tap auth login") + require.ErrorContains(t, mcpErr, "UNAUTHORIZED") + require.ErrorContains(t, mcpErr, "tap auth login") + require.Empty(t, drain()) +} diff --git a/pkg/tapapi/tapapi.go b/pkg/tapapi/tapapi.go index 6ce10fa..1b8b4a9 100644 --- a/pkg/tapapi/tapapi.go +++ b/pkg/tapapi/tapapi.go @@ -19,6 +19,7 @@ const ( PathBacklinks = "/tap/backlinks" PathTags = "/tap/tags" PathCat = "/tap/cat" + PathSearch = "/tap/search" ) // RelatedRequest asks for a node set's links or backlinks in one KEG, paged @@ -83,3 +84,104 @@ type CatNode struct { type CatResponse struct { Nodes []CatNode `json:"nodes"` } + +// SearchRouteHeader is a response header the Hub's search route sets to +// SearchRouteHeaderValue on every response, success and error alike. It tells +// the route's own answers apart from a Hub without the route: such a Hub +// answers 404 or 405, often with the same NOT_FOUND envelope the route uses +// for an unreadable KEG, but never with this header. +const ( + SearchRouteHeader = "Tapper-Search" + SearchRouteHeaderValue = "ranked" +) + +// SearchMaxKegs is the most KEGs one SearchRequest may name. The Hub refuses +// a longer Kegs list with 400. +const SearchMaxKegs = 50 + +// SearchRequest asks for nodes ranked by relevance to Query across every KEG +// the caller may read on this Hub, narrowed to Kegs when it is non-empty: an +// empty Kegs means every readable KEG. A request carrying the orientation +// proof (keg.OrientationHeaderName) is governed: there an empty Kegs means +// every readable KEG in the proof's flight cover, which the Hub resolves +// itself, so a client never has to name a cover wider than SearchMaxKegs. The +// Hub resolves each KEG through its authority boundary; naming a KEG only +// narrows and never grants access. +// +// Cursor continues a previous response's NextCursor; it is opaque and passed +// through unchanged. Limit 0 lets the Hub choose the page size. Defaults and +// clamping live only on the Hub. +type SearchRequest struct { + Query string `json:"query"` + // Kegs are qualified references, "@namespace/alias". + Kegs []string `json:"kegs,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// Span is a highlighted range of a hit's Snippet in rune offsets: Start is +// inclusive and End exclusive. +type Span struct { + Start int `json:"start"` + End int `json:"end"` +} + +// Search hit fields name where a hit matched. +const ( + SearchFieldTitle = "title" + SearchFieldMeta = "meta" + SearchFieldBody = "body" +) + +// Search hit sources name the ranking signals that found a hit. +const ( + SearchSourceKeyword = "keyword" + SearchSourceSemantic = "semantic" +) + +// Search modes report how a response was ranked. SearchModeKeyword means +// semantic ranking was not used: the Hub has no embedding model or degraded. +const ( + SearchModeKeyword = "keyword" + SearchModeHybrid = "hybrid" +) + +// SearchHit is one ranked node. Hits arrive in a total order: Score +// descending, then Keg, then NodeID. +type SearchHit struct { + // Ref is the qualified node reference, "keg:@namespace/alias/id". + Ref string `json:"ref"` + // Keg is the qualified KEG reference, "@namespace/alias". + Keg string `json:"keg"` + NodeID string `json:"node_id"` + Title string `json:"title"` + Score float64 `json:"score"` + // Fields lists where the node matched: SearchFieldTitle, SearchFieldMeta, + // SearchFieldBody. + Fields []string `json:"fields"` + // Sources lists the signals that found the node: SearchSourceKeyword, + // SearchSourceSemantic. + Sources []string `json:"sources,omitempty"` + Snippet string `json:"snippet,omitempty"` + // Highlights are the matched ranges of Snippet. + Highlights []Span `json:"highlights,omitempty"` +} + +// TermExpansion reports how the Hub interpreted one query term: the lexemes it +// searched for, and whether they came from typo expansion. +type TermExpansion struct { + Term string `json:"term"` + Lexemes []string `json:"lexemes"` + Typo bool `json:"typo"` +} + +// SearchResponse is one page of ranked hits. NextCursor is empty on the last +// page. Mode is SearchModeKeyword or SearchModeHybrid. Warnings report +// partial results the caller should know about. +type SearchResponse struct { + Hits []SearchHit `json:"hits"` + NextCursor string `json:"next_cursor,omitempty"` + Terms []TermExpansion `json:"terms,omitempty"` + Mode string `json:"mode,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} diff --git a/pkg/tapper/config.go b/pkg/tapper/config.go index ca39823..b5e8bdb 100644 --- a/pkg/tapper/config.go +++ b/pkg/tapper/config.go @@ -746,7 +746,7 @@ func (cfg *Config) resolveNamespaceHub(ns, hubName string) (string, string, HubE entry, ok := cfg.Hub(hubName) if !ok { - return "", "", HubEntry{}, fmt.Errorf("hub %q is not configured", hubName) + return "", "", HubEntry{}, hubNotConfiguredError(hubName) } if ns == "" { diff --git a/pkg/tapper/config_selection.go b/pkg/tapper/config_selection.go index 341e27f..b4bc09a 100644 --- a/pkg/tapper/config_selection.go +++ b/pkg/tapper/config_selection.go @@ -74,7 +74,7 @@ func (s *ConfigService) PinHub() error { name := cfg.resolveHubName() entry, ok := cfg.Hub(name) if !ok { - return fmt.Errorf("hub %q is not configured", name) + return hubNotConfiguredError(name) } url, err := normalizeHubURL(CanonicalConfiguredHubURL(entry.URL)) if err != nil { @@ -97,7 +97,7 @@ func (s *ConfigService) SelectedHub(explicit string) (string, HubEntry, error) { } entry, ok := cfg.Hub(name) if !ok { - return "", HubEntry{}, fmt.Errorf("hub %q is not configured", name) + return "", HubEntry{}, hubNotConfiguredError(name) } if entry.invalid != nil { return "", HubEntry{}, fmt.Errorf("hub %q: %w", name, entry.invalid) diff --git a/pkg/tapper/flight.go b/pkg/tapper/flight.go index 146ece6..8698c3b 100644 --- a/pkg/tapper/flight.go +++ b/pkg/tapper/flight.go @@ -657,14 +657,12 @@ func parseFlightCoverEntry(entry string) (FlightCover, bool) { return FlightCover{Namespace: strings.TrimSpace(ns), Keg: name, Role: normalizeFlightRole(role)}, true } -// RoleFor reports the flight-scoped role for a keg. An empty cover denies all -// KEG access. -// Manifests normally arrive through normalizeFlightManifest, which folds -// legacy AllowedKegs into Cover; the fallback here only covers Flight values -// constructed by hand. -func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { +// coverEntries returns the cover RoleFor evaluates, trimmed and without +// entries that name no KEG: Cover, or for a Flight that carries only legacy +// AllowedKegs, those entries (editor unless the entry names a role). +func (f *Flight) coverEntries() []FlightCover { if f == nil { - return "", false + return nil } cover := append([]FlightCover(nil), f.Cover...) if len(cover) == 0 && len(f.AllowedKegs) > 0 { @@ -677,6 +675,24 @@ func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { } } } + out := cover[:0] + for _, c := range cover { + c.Namespace = strings.TrimPrefix(strings.TrimSpace(c.Namespace), "@") + c.Keg = strings.TrimSpace(c.Keg) + if c.Keg != "" { + out = append(out, c) + } + } + return out +} + +// RoleFor reports the flight-scoped role for a keg. An empty cover denies all +// KEG access. +// Manifests normally arrive through normalizeFlightManifest, which folds +// legacy AllowedKegs into Cover; the fallback here only covers Flight values +// constructed by hand. +func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { + cover := f.coverEntries() if len(cover) == 0 { return "", false } @@ -684,11 +700,6 @@ func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { kegName = strings.TrimSpace(kegName) alias = strings.TrimSpace(alias) for _, c := range cover { - c.Namespace = strings.TrimPrefix(strings.TrimSpace(c.Namespace), "@") - c.Keg = strings.TrimSpace(c.Keg) - if c.Keg == "" { - continue - } if c.Namespace == "" { if (alias != "" && c.Keg == alias) || (kegName != "" && c.Keg == kegName) { return normalizeFlightRole(c.Role), true diff --git a/pkg/tapper/hub_kegs.go b/pkg/tapper/hub_kegs.go index 7dd3616..427fdf5 100644 --- a/pkg/tapper/hub_kegs.go +++ b/pkg/tapper/hub_kegs.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/jlrickert/tapper/pkg/apicontract" "github.com/jlrickert/tapper/pkg/keg" @@ -33,22 +34,46 @@ type HubKeg struct { Description string `json:"description"` Visibility string `json:"visibility"` Role string `json:"role"` + // KegVersion is the KEG's immutable storage version (keg.KegVersionV1 or + // keg.KegVersionV2). Hubs that predate versioned KEGs omit it; an empty + // value reads as KEGv1. + KegVersion string `json:"kegv,omitempty"` } -// CreateKeg asks the hub to create @namespace/alias via -// POST /api/v1/@{namespace}/kegs. A 409 is returned as an error wrapping -// keg.ErrExist so callers can detect "already exists" with errors.Is; 401 -// wraps ErrTokenRejected; 403 wraps keg.ErrForbidden; other non-2xx statuses surface the hub's status line. +// HubKegCreate is the body of the Hub's keg-create request. An empty +// KegVersion is omitted so the Hub applies its default (KEGv1), which keeps +// the request acceptable to Hubs that predate versioned KEGs. +type HubKegCreate struct { + Alias string `json:"alias"` + Title string `json:"title"` + Visibility string `json:"visibility"` + KegVersion string `json:"kegv,omitempty"` +} + +// CreateKeg asks the hub to create @namespace/alias with the Hub's default +// KEG version. See CreateHubKeg. func CreateKeg(ctx context.Context, hubURL, token, namespace, alias, title, visibility string) error { + return CreateHubKeg(ctx, hubURL, token, namespace, HubKegCreate{Alias: alias, Title: title, Visibility: visibility}) +} + +// CreateHubKeg asks the hub to create a KEG in namespace via +// POST /api/v1/@{namespace}/kegs. A 409 maps by the error envelope's code: +// KEG_VERSION_UNSUPPORTED (the Hub does not offer the requested KEG version, +// such as KEGv2 while its preview is off) wraps keg.ErrKegVersionUnsupported, +// and EXIST, CONFLICT, or a 409 without a code wraps keg.ErrExist so callers +// can detect "already exists" with errors.Is. 400 wraps keg.ErrInvalid (an unknown KEG +// version, for example); 401 wraps ErrTokenRejected; 403 wraps +// keg.ErrForbidden; other non-2xx statuses surface the hub's status line. +func CreateHubKeg(ctx context.Context, hubURL, token, namespace string, in HubKegCreate) error { base, err := normalizeHubURL(hubURL) if err != nil { return err } - payload, _ := json.Marshal(map[string]string{ - "alias": alias, - "title": title, - "visibility": visibility, - }) + payload, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("hub: encode create-keg request: %w", err) + } + alias := in.Alias endpoint := fmt.Sprintf("%s/api/v1/@%s/kegs", base, namespace) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { @@ -66,9 +91,20 @@ func CreateKeg(ctx context.Context, hubURL, token, namespace, alias, title, visi switch resp.StatusCode { case http.StatusCreated, http.StatusOK: - return nil + return checkCreatedKegVersion(resp, namespace, alias, in.KegVersion) case http.StatusConflict: - return fmt.Errorf("hub: keg @%s/%s already exists: %w", namespace, alias, keg.ErrExist) + code, msg := readHubErrorEnvelope(resp) + // Hubs that predate the EXIST code report a duplicate KEG as a + // generic CONFLICT, so both still mean "already exists". + if code == "" || code == keg.RemoteCodeExist || code == keg.RemoteCodeConflict { + return fmt.Errorf("hub: keg @%s/%s already exists: %w", namespace, alias, keg.ErrExist) + } + if msg == "" { + msg = resp.Status + } + return fmt.Errorf("hub: create keg @%s/%s: %w", namespace, alias, keg.RemoteErrorFromCode(code, resp.StatusCode, msg)) + case http.StatusBadRequest: + return fmt.Errorf("hub: create keg @%s/%s: %w%s", namespace, alias, keg.ErrInvalid, readHubError(resp)) case http.StatusForbidden: return fmt.Errorf("hub: %w (%s)%s", keg.ErrForbidden, resp.Status, readHubError(resp)) case http.StatusUnauthorized: @@ -78,6 +114,61 @@ func CreateKeg(ctx context.Context, hubURL, token, namespace, alias, title, visi } } +// KegVersionNotAppliedError reports a KEG the Hub created at a version other +// than the one requested. A Hub that predates versioned KEGs ignores the +// requested version and creates a KEGv1 KEG. The KEG exists: the error +// reports the outcome and never deletes it. It unwraps to +// keg.ErrKegVersionUnsupported. +type KegVersionNotAppliedError struct { + Namespace string + Alias string + Requested string + Created string +} + +func (e *KegVersionNotAppliedError) Error() string { + return fmt.Sprintf("hub: created @%s/%s as %s (%s), not the requested %s (%s), because the Hub does not support KEG version %s; "+ + "the KEG exists: use it as %s or delete it", + e.Namespace, e.Alias, kegVersionName(e.Created), e.Created, kegVersionName(e.Requested), e.Requested, e.Requested, kegVersionName(e.Created)) +} + +func (e *KegVersionNotAppliedError) Unwrap() error { return keg.ErrKegVersionUnsupported } + +// kegVersionName names a KEG version for messages. +func kegVersionName(v string) string { + if label := KegVersionLabel(v); label != "" { + return label + } + if v == keg.KegVersionV1 { + return "KEGv1" + } + return "KEG version " + v +} + +// checkCreatedKegVersion compares the version the Hub reports in its create +// response with the requested one. A Hub that predates versioned KEGs reports +// no version and created KEGv1, which satisfies only a KEGv1 request. +func checkCreatedKegVersion(resp *http.Response, namespace, alias, requested string) error { + requested = strings.TrimSpace(requested) + if requested == "" { + return nil + } + var created struct { + KegVersion string `json:"kegv"` + } + if b, err := io.ReadAll(io.LimitReader(resp.Body, 4096)); err == nil { + _ = json.Unmarshal(b, &created) + } + got := strings.TrimSpace(created.KegVersion) + if got == "" { + got = keg.KegVersionV1 + } + if got == requested { + return nil + } + return &KegVersionNotAppliedError{Namespace: namespace, Alias: alias, Requested: requested, Created: got} +} + // ListUserKegs returns every keg the hub reports the bearer token can reach // (namespace membership + grants) via GET /api/v1/kegs. func ListUserKegs(ctx context.Context, hubURL, token string) ([]HubKeg, error) { @@ -123,17 +214,27 @@ func ListUserKegs(ctx context.Context, hubURL, token string) ([]HubKeg, error) { // readHubError best-effort extracts the hub's {"error": ...} message from a // failed response so the surfaced error carries the hub's own explanation. func readHubError(resp *http.Response) string { + if _, msg := readHubErrorEnvelope(resp); msg != "" { + return ": " + msg + } + return "" +} + +// readHubErrorEnvelope best-effort decodes the hub's {"error", "code"} +// envelope from a failed response. Either value is empty when absent. +func readHubErrorEnvelope(resp *http.Response) (code, msg string) { b, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) if err != nil || len(b) == 0 { - return "" + return "", "" } var e struct { Error string `json:"error"` + Code string `json:"code"` } - if json.Unmarshal(b, &e) == nil && e.Error != "" { - return ": " + e.Error + if json.Unmarshal(b, &e) != nil { + return "", "" } - return "" + return strings.TrimSpace(e.Code), strings.TrimSpace(e.Error) } // hubHTTPClient refuses redirects so credentials and mutations stay on the selected Hub. diff --git a/pkg/tapper/hub_kegs_test.go b/pkg/tapper/hub_kegs_test.go index 018ca46..cb3d527 100644 --- a/pkg/tapper/hub_kegs_test.go +++ b/pkg/tapper/hub_kegs_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" "net/http" - + "sync/atomic" "testing" "github.com/jlrickert/tapper/internal/testapi" @@ -39,15 +39,44 @@ func TestCreateKeg_Success(t *testing.T) { func TestCreateKeg_Conflict(t *testing.T) { t.Parallel() + // EXIST is the current Hub's code; CONFLICT comes from Hubs that predate + // it, and a bare 409 carries no envelope at all. + for name, body := range map[string]string{ + "EXIST": `{"error":"keg @jlrickert/example already exists","code":"EXIST"}`, + "CONFLICT": `{"error":"keg already exists","code":"CONFLICT"}`, + "no code": ``, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + err := tapper.CreateKeg(context.Background(), srv.URL, "tok", "jlrickert", "example", "", "") + require.ErrorIs(t, err, keg.ErrExist, "409 must map to keg.ErrExist so callers detect 'already exists'") + require.ErrorContains(t, err, "already exists") + }) + } +} + +func TestCreateHubKeg_VersionUnsupportedIsNotExist(t *testing.T) { + t.Parallel() + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "keg already exists", "code": "CONFLICT"}) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "KEG version 2026-09 is not enabled on this hub", "code": keg.RemoteCodeKegVersionUnsupported, + }) })) defer srv.Close() - err := tapper.CreateKeg(context.Background(), srv.URL, "tok", "jlrickert", "example", "", "") - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrExist, "409 must map to keg.ErrExist so callers detect 'already exists'") + err := tapper.CreateHubKeg(context.Background(), srv.URL, "tok", "acme", tapper.HubKegCreate{Alias: "graph", KegVersion: keg.KegVersionV2}) + require.ErrorIs(t, err, keg.ErrKegVersionUnsupported) + require.NotErrorIs(t, err, keg.ErrExist, "a disabled KEG version is not an existing KEG") + require.ErrorContains(t, err, "is not enabled on this hub") + require.NotContains(t, err.Error(), "already exists") } func TestCreateKeg_Unauthorized(t *testing.T) { @@ -135,3 +164,137 @@ func TestCreateKeg_ForbiddenPreservesExplanation(t *testing.T) { require.NotErrorIs(t, err, tapper.ErrTokenRejected) require.ErrorContains(t, err, "namespace membership required") } + +func TestCreateHubKeg_SendsKegVersion(t *testing.T) { + t.Parallel() + + var body map[string]any + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"namespace": "acme", "alias": "graph", "kegv": body["kegv"]}) + })) + defer srv.Close() + + err := tapper.CreateHubKeg(context.Background(), srv.URL, "tok", "acme", tapper.HubKegCreate{Alias: "graph", KegVersion: keg.KegVersionV2}) + require.NoError(t, err) + require.Equal(t, keg.KegVersionV2, body["kegv"]) + require.Equal(t, "graph", body["alias"]) +} + +func TestCreateHubKeg_ReportsAVersionTheHubDidNotApply(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reply string + requested string + created string + }{ + "hub predating kegv echoes no version": {`{"namespace":"acme","alias":"graph"}`, keg.KegVersionV2, keg.KegVersionV1}, + "hub reports another version": {`{"namespace":"acme","alias":"graph","kegv":"2025-07"}`, keg.KegVersionV2, keg.KegVersionV1}, + "empty create response": {``, keg.KegVersionV2, keg.KegVersionV1}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + var deletes atomic.Int32 + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deletes.Add(1) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(tc.reply)) + })) + defer srv.Close() + + err := tapper.CreateHubKeg(context.Background(), srv.URL, "tok", "acme", tapper.HubKegCreate{Alias: "graph", KegVersion: tc.requested}) + var notApplied *tapper.KegVersionNotAppliedError + require.ErrorAs(t, err, ¬Applied) + require.Equal(t, tc.created, notApplied.Created) + require.ErrorIs(t, err, keg.ErrKegVersionUnsupported) + require.ErrorContains(t, err, "created @acme/graph as KEGv1 (2025-07)") + require.ErrorContains(t, err, "does not support KEG version 2026-09") + require.Zero(t, deletes.Load(), "the created KEG is reported, never deleted") + }) + } +} + +func TestCreateHubKeg_KegV1RequestAcceptsAnUnversionedReply(t *testing.T) { + t.Parallel() + + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"namespace":"acme","alias":"notes"}`)) + })) + defer srv.Close() + + err := tapper.CreateHubKeg(context.Background(), srv.URL, "tok", "acme", tapper.HubKegCreate{Alias: "notes", KegVersion: keg.KegVersionV1}) + require.NoError(t, err, "a Hub that reports no version created KEGv1, which is what was asked for") +} + +func TestCreateKeg_OmitsKegVersionByDefault(t *testing.T) { + t.Parallel() + + var body map[string]any + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + require.NoError(t, tapper.CreateKeg(context.Background(), srv.URL, "tok", "acme", "notes", "", "")) + _, present := body["kegv"] + require.False(t, present, "an unversioned create must not send kegv, so Hubs that predate versioned KEGs accept it") +} + +func TestCreateHubKeg_BadRequestMapsToErrInvalid(t *testing.T) { + t.Parallel() + + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "KEGv2 preview is disabled", "code": "INVALID"}) + })) + defer srv.Close() + + err := tapper.CreateHubKeg(context.Background(), srv.URL, "tok", "acme", tapper.HubKegCreate{Alias: "graph", KegVersion: keg.KegVersionV2}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "KEGv2 preview is disabled") +} + +func TestListUserKegs_DecodesKegVersion(t *testing.T) { + t.Parallel() + + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"namespace":"acme","alias":"graph","kegv":"2026-09"},{"namespace":"acme","alias":"notes"}]`)) + })) + defer srv.Close() + + kegs, err := tapper.ListUserKegs(context.Background(), srv.URL, "tok") + require.NoError(t, err) + require.Equal(t, keg.KegVersionV2, kegs[0].KegVersion) + require.Empty(t, kegs[1].KegVersion, "an unreported version stays empty and reads as KEGv1") +} + +func TestKegTitleWithVersion(t *testing.T) { + t.Parallel() + + require.Equal(t, "Notes", tapper.KegTitleWithVersion("Notes", "")) + require.Equal(t, "Notes", tapper.KegTitleWithVersion("Notes", keg.KegVersionV1)) + require.Equal(t, "Graph (KEGv2 preview)", tapper.KegTitleWithVersion("Graph", keg.KegVersionV2)) + require.Equal(t, "KEGv2 preview", tapper.KegTitleWithVersion(" ", keg.KegVersionV2)) +} + +func TestNormalizeKegVersion(t *testing.T) { + t.Parallel() + + v, err := tapper.NormalizeKegVersion(" 2026-09 ") + require.NoError(t, err) + require.Equal(t, keg.KegVersionV2, v) + v, err = tapper.NormalizeKegVersion("") + require.NoError(t, err) + require.Empty(t, v) + _, err = tapper.NormalizeKegVersion("2027-01") + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "keg version") +} diff --git a/pkg/tapper/tap_flight.go b/pkg/tapper/tap_flight.go index 744be22..434da97 100644 --- a/pkg/tapper/tap_flight.go +++ b/pkg/tapper/tap_flight.go @@ -268,12 +268,21 @@ func (t *Tap) enforceFlightSnapshot(flight *Flight, k keg.Keg, want FlightRole) if flight == nil || k == nil { return nil } + return t.enforceFlightTarget(flight, k.Target(), want) +} + +// enforceFlightTarget applies a resolved flight snapshot to a KEG target that +// has not been opened, such as a ranked search scope. +func (t *Tap) enforceFlightTarget(flight *Flight, target *keg.Target, want FlightRole) error { + if flight == nil { + return nil + } var alias, namespace, kegName string - if k.Target() != nil { - namespace = k.Target().Namespace - kegName = k.Target().KegName + if target != nil { + namespace = target.Namespace + kegName = target.KegName if cfg, cErr := t.ConfigService.Config(); cErr == nil { - alias = cfg.LookupAliasForTarget(t.Runtime, k.Target().String()) + alias = cfg.LookupAliasForTarget(t.Runtime, target.String()) } } role, ok := flight.RoleFor(alias, namespace, kegName) @@ -288,8 +297,8 @@ func (t *Tap) enforceFlightSnapshot(flight *Flight, k keg.Keg, want FlightRole) if label == "" && kegName != "" { label = kegName } - if label == "" && k.Target() != nil { - label = k.Target().String() + if label == "" && target != nil { + label = target.String() } return &FlightRestrictionError{Flight: flight.Name, Keg: label, Want: want, Got: role} } diff --git a/pkg/tapper/tap_info.go b/pkg/tapper/tap_info.go index da8a8c0..95eb1e7 100644 --- a/pkg/tapper/tap_info.go +++ b/pkg/tapper/tap_info.go @@ -301,6 +301,10 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { return "", fmt.Errorf("unable to read keg settings: %w", err) } summary := info.Summary + if summary == nil { + // A KEG version without a KEGv1 node summary reports none. + summary = &keg.KegSummary{} + } type capability struct { Supported bool `yaml:"supported" json:"supported"` @@ -322,6 +326,7 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { Ref string `yaml:"ref" json:"ref"` Flight string `yaml:"flight" json:"flight"` Description string `yaml:"description" json:"description"` + KegVersion string `yaml:"kegv,omitempty" json:"kegv,omitempty"` NodeCount int `yaml:"node_count" json:"node_count"` Files capability `yaml:"files" json:"files"` Images capability `yaml:"images" json:"images"` @@ -330,14 +335,15 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { identity := t.resolveIdentity(opts.KegTargetOptions) out := diagnostics{ - Hub: identity.Hub, - Namespace: identity.Namespace, - Keg: identity.Keg, - Ref: canonicalKegRef(identity.Ref), - Flight: identity.Flight, - NodeCount: summary.NodeCount, - Files: capability{Supported: summary.Files.Supported}, - Images: capability{Supported: summary.Images.Supported}, + Hub: identity.Hub, + Namespace: identity.Namespace, + Keg: identity.Keg, + Ref: canonicalKegRef(identity.Ref), + Flight: identity.Flight, + KegVersion: info.KegVersion, + NodeCount: summary.NodeCount, + Files: capability{Supported: summary.Files.Supported}, + Images: capability{Supported: summary.Images.Supported}, } // Populate summary from the keg settings. diff --git a/pkg/tapper/tap_init.go b/pkg/tapper/tap_init.go index fe40a5b..9df3b75 100644 --- a/pkg/tapper/tap_init.go +++ b/pkg/tapper/tap_init.go @@ -15,6 +15,11 @@ type InitOptions struct { Keg string Visibility string + // KegVersion selects the new KEG's immutable storage version + // (keg.KegVersionV1 or keg.KegVersionV2). Empty lets the Hub choose its + // default, KEGv1. + KegVersion string + // RequireBootstrap rejects config-driven creation until user setup exists. RequireBootstrap bool } @@ -24,6 +29,20 @@ type CreateKegOptions struct { Keg string Title string Visibility string + // KegVersion is validated with NormalizeKegVersion; empty lets the Hub + // choose. + KegVersion string +} + +// NormalizeKegVersion trims a requested KEG version and rejects an unknown +// one with keg.ErrInvalid. Empty stays empty: the Hub chooses the default. +func NormalizeKegVersion(raw string) (string, error) { + v := strings.TrimSpace(raw) + if v != "" && !keg.ValidKegVersion(v) { + return "", fmt.Errorf("keg version %q must be %s (KEGv1) or %s (KEGv2 preview): %w", + v, keg.KegVersionV1, keg.KegVersionV2, keg.ErrInvalid) + } + return v, nil } // InitKeg creates a KEG through the configured hub creation endpoint. @@ -32,6 +51,9 @@ func (t *Tap) InitKeg(ctx context.Context, options InitOptions) (*keg.Target, er if err != nil { return nil, err } + if options.KegVersion, err = NormalizeKegVersion(options.KegVersion); err != nil { + return nil, err + } if options.RequireBootstrap && !t.ConfigService.UserConfigExists() { return nil, ErrNotBootstrapped } @@ -65,9 +87,11 @@ func (t *Tap) initRemoteKeg(ctx context.Context, options InitOptions, target *ke } token := t.hubTokenForTarget(target) if token == "" { - return nil, fmt.Errorf("not logged in to hub %q (run `tap auth login --hub %s`)", hubName, hubURL) + return nil, notLoggedInError(hubName, hubURL) } - if err := CreateKeg(ctx, hubURL, token, namespace, name, options.Title, options.Visibility); err != nil { + if err := CreateHubKeg(ctx, hubURL, token, namespace, HubKegCreate{ + Alias: name, Title: options.Title, Visibility: options.Visibility, KegVersion: options.KegVersion, + }); err != nil { return nil, err } return target, nil diff --git a/pkg/tapper/tap_init_test.go b/pkg/tapper/tap_init_test.go index 6600dd0..cafcc4b 100644 --- a/pkg/tapper/tap_init_test.go +++ b/pkg/tapper/tap_init_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "sync/atomic" "testing" @@ -125,3 +126,48 @@ func TestInitKeg_RemoteCreate_Conflict(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, keg.ErrExist, "an existing remote keg must fail with ErrExist (409)") } + +func TestInitKeg_RemoteCreate_SendsKegVersion(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + require.NoError(t, fx.Setwd("/home/testuser")) + require.NoError(t, fx.Runtime().Env().Set("TEST_TOK", "tok")) + + var body map[string]any + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"namespace": "teamns", "alias": "graph", "kegv": body["kegv"]}) + })) + defer srv.Close() + + tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) + require.NoError(t, err) + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(remoteInitConfig(srv.URL)), 0o644)) + + _, err = tap.InitKeg(fx.Context(), tapper.InitOptions{Keg: "@teamns/graph", KegVersion: keg.KegVersionV2}) + require.NoError(t, err) + require.Equal(t, keg.KegVersionV2, body["kegv"]) +} + +func TestInitKeg_RejectsUnknownKegVersion(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + require.NoError(t, fx.Setwd("/home/testuser")) + require.NoError(t, fx.Runtime().Env().Set("TEST_TOK", "tok")) + + var calls atomic.Int32 + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) + require.NoError(t, err) + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(remoteInitConfig(srv.URL)), 0o644)) + + _, err = tap.InitKeg(fx.Context(), tapper.InitOptions{Keg: "@teamns/graph", KegVersion: "2027-01"}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.Zero(t, calls.Load(), "an unknown version must be rejected before contacting the Hub") +} diff --git a/pkg/tapper/tap_namespace.go b/pkg/tapper/tap_namespace.go index 64e7431..9e5b463 100644 --- a/pkg/tapper/tap_namespace.go +++ b/pkg/tapper/tap_namespace.go @@ -6,6 +6,8 @@ import ( "net/url" "sort" "strings" + + "github.com/jlrickert/tapper/pkg/keg" ) // Namespace administration: listing namespaces, managing membership roles, and @@ -200,7 +202,7 @@ func (t *Tap) resolveNamespaceHub(namespace, hubOverride string) (ns, hubURL, to } entry, ok := cfg.Hub(hubName) if !ok { - return "", "", "", fmt.Errorf("hub %q is not configured", hubName) + return "", "", "", hubNotConfiguredError(hubName) } url, tok, err := remoteHubEndpoint(t, hubName, entry) if err != nil { @@ -222,7 +224,7 @@ func (t *Tap) resolveHubEndpoint(hubOverride string) (hubURL, token string, err } entry, ok := cfg.Hub(hubName) if !ok { - return "", "", fmt.Errorf("hub %q is not configured", hubName) + return "", "", hubNotConfiguredError(hubName) } return remoteHubEndpoint(t, hubName, entry) } @@ -262,15 +264,42 @@ func remoteHubEndpoint(t *Tap, hubName string, entry HubEntry) (hubURL, token st } url := strings.TrimSpace(entry.URL) if url == "" { - return "", "", fmt.Errorf("hub %q has no url configured", hubName) + return "", "", sentinelError{msg: fmt.Sprintf("hub %q has no url configured", hubName), sentinel: keg.ErrInvalid} } tok := t.hubToken(entry) if tok == "" { - return "", "", fmt.Errorf("hub %q has no auth token (run `tap auth login --hub %s`)", hubName, url) + return "", "", notLoggedInError(hubName, url) } return url, tok, nil } +// sentinelError keeps msg as the whole error text while matching sentinel +// with errors.Is, so a configuration message reads unchanged on the CLI and +// still maps to its wire code (and MCP guidance) on every surface. +type sentinelError struct { + msg string + sentinel error +} + +func (e sentinelError) Error() string { return e.msg } +func (e sentinelError) Unwrap() error { return e.sentinel } + +// hubNotConfiguredError reports a hub name absent from configuration. It +// wraps keg.ErrInvalid: the request named, or defaulted to, a hub that does +// not exist. +func hubNotConfiguredError(hubName string) error { + return sentinelError{msg: fmt.Sprintf("hub %q is not configured", hubName), sentinel: keg.ErrInvalid} +} + +// notLoggedInError reports a configured hub with no credential. It wraps +// keg.ErrUnauthorized, which every surface reports as needing a login. +func notLoggedInError(hubName, hubURL string) error { + return sentinelError{ + msg: fmt.Sprintf("not logged in to hub %q (run `tap auth login --hub %s`)", hubName, hubURL), + sentinel: keg.ErrUnauthorized, + } +} + // NamespaceSearchOptions searches the namespaces visible across a hub: people // and org namespaces, not only the caller's own. An empty Query browses. type NamespaceSearchOptions struct { diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index 13a97b7..ec4275f 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -206,8 +206,11 @@ type OrientationKeg struct { Role string Source string Visibility string - FlightCap string - Flights []string + // KegVersion is the KEG's immutable storage version; empty means the Hub + // predates versioned KEGs and the KEG is KEGv1. + KegVersion string + FlightCap string + Flights []string } // OrientationAuthority describes the connection-pinned launch root and the flight @@ -289,6 +292,7 @@ func (t *Tap) orientKegsForHub(ctx context.Context, _ *Config, hubName string, e Role: k.Role, Source: hubName, Visibility: k.Visibility, + KegVersion: k.KegVersion, } out = append(out, row) } @@ -567,7 +571,7 @@ func writeOrientationKegTable(b *strings.Builder, kegs []OrientationKeg, _ strin b.WriteString("| Title | Reference | Description | Role |\n| --- | --- | --- | --- |\n") } for _, k := range kegs { - fmt.Fprintf(b, "| %s | `%s` | %s | %s |", orientationTableCell(resourceTitle(k.Title, k.Ref)), k.Ref, orientationTableCell(DescriptionPreview(k.Description)), EffectiveOrientationRole(k)) + fmt.Fprintf(b, "| %s | `%s` | %s | %s |", orientationTableCell(KegTitleWithVersion(resourceTitle(k.Title, k.Ref), k.KegVersion)), k.Ref, orientationTableCell(DescriptionPreview(k.Description)), EffectiveOrientationRole(k)) if provenance { fmt.Fprintf(b, " %s | %d |", orientationTableCell(k.CoverSource), k.Distance) } @@ -575,6 +579,29 @@ func writeOrientationKegTable(b *strings.Builder, kegs []OrientationKeg, _ strin } } +// KegVersionLabel names a non-default KEG version for display. KEGv1, the +// default, and an unreported version both yield "", so their output is +// unchanged. +func KegVersionLabel(version string) string { + if version == keg.KegVersionV2 { + return "KEGv2 preview" + } + return "" +} + +// KegTitleWithVersion appends a KEG's version label to its display title. +func KegTitleWithVersion(title, version string) string { + label := KegVersionLabel(version) + switch { + case label == "": + return title + case strings.TrimSpace(title) == "": + return label + default: + return title + " (" + label + ")" + } +} + func orientationTableCell(value string) string { value = html.EscapeString(strings.TrimSpace(value)) diff --git a/pkg/tapper/tap_orient_internal_test.go b/pkg/tapper/tap_orient_internal_test.go index a3c0f9c..7139776 100644 --- a/pkg/tapper/tap_orient_internal_test.go +++ b/pkg/tapper/tap_orient_internal_test.go @@ -1,6 +1,11 @@ package tapper -import "testing" +import ( + "strings" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" +) func TestFlightCapForKeg_OutsideCoverDenies(t *testing.T) { flight := &Flight{FlightManifest: FlightManifest{ @@ -20,3 +25,18 @@ func TestFlightCapForKeg_EmptyCoverDeniesAll(t *testing.T) { t.Fatalf("flightCapForKeg empty cover = %q, %t; want empty, false", capRole, ok) } } + +func TestOrientationKegTable_LabelsKegVersion(t *testing.T) { + var b strings.Builder + writeOrientationKegTable(&b, []OrientationKeg{ + {Ref: "@acme/graph", Title: "Graph", Role: "viewer", KegVersion: keg.KegVersionV2}, + {Ref: "@acme/notes", Title: "Notes", Role: "viewer", KegVersion: keg.KegVersionV1}, + }, "") + out := b.String() + if !strings.Contains(out, "| Graph (KEGv2 preview) | `@acme/graph`") { + t.Fatalf("KEGv2 row is not labeled:\n%s", out) + } + if !strings.Contains(out, "| Notes | `@acme/notes`") { + t.Fatalf("KEGv1 row must stay unlabeled:\n%s", out) + } +} diff --git a/pkg/tapper/tap_search.go b/pkg/tapper/tap_search.go new file mode 100644 index 0000000..22863bf --- /dev/null +++ b/pkg/tapper/tap_search.go @@ -0,0 +1,423 @@ +package tapper + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/jlrickert/tapper/pkg/apicontract" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapapi" +) + +// ErrRankedSearchUnavailable reports that ranked search cannot run for the +// target: it is not a Hub KEG, or its Hub predates ranked search. Regex +// matching (Tap.Grep, `tap node grep`, node_grep) still works in both cases. +// It matches keg.ErrNotSupported, so every surface reports NOT_SUPPORTED and +// points the caller at a supported tool rather than at a partial write. +var ErrRankedSearchUnavailable error = rankedSearchUnavailableError{} + +type rankedSearchUnavailableError struct{} + +func (rankedSearchUnavailableError) Error() string { return "ranked search unavailable" } + +func (rankedSearchUnavailableError) Is(target error) bool { return target == keg.ErrNotSupported } + +// searchResponseLimit bounds how much of a search response is read. +const searchResponseLimit = 8 << 20 + +// SearchOptions configures ranked node search. +// +// With Keg set, the search covers that one Hub KEG. Otherwise it covers every +// KEG the caller may read on the selected Hub, narrowed to Kegs when given and +// to the active flight's cover unless BypassFlightRestrictions is set. A +// governed call that carries the orientation proof leaves an unscoped search +// for the Hub to scope to the cover; otherwise the client names the cover, +// which must fit in tapapi.SearchMaxKegs. +type SearchOptions struct { + KegTargetOptions + + // Kegs narrows a whole-Hub search to these "@namespace/alias" references, + // at most tapapi.SearchMaxKegs of them. It cannot be combined with Keg. + Kegs []string + + // Query is plain terms, "quoted phrases", and -exclusions. The Hub parses + // it. + Query string + + // Cursor continues the page a previous response's NextCursor named. It is + // opaque and sent unchanged. + Cursor string + + // Limit caps the hits returned; 0 lets the Hub choose. The Hub owns the + // default and the maximum. + Limit int +} + +// Validate checks the request shape every surface shares and returns the +// canonical, de-duplicated Kegs. A provider serving search in-process calls it +// before running the search so its surface refuses what Tap.Search refuses. +func (o SearchOptions) Validate() ([]string, error) { + if strings.TrimSpace(o.Query) == "" { + return nil, fmt.Errorf("search query must not be empty: %w", keg.ErrInvalid) + } + if o.Limit < 0 { + return nil, fmt.Errorf("search limit must be >= 0: %w", keg.ErrInvalid) + } + if strings.TrimSpace(o.Keg) != "" && len(o.Kegs) > 0 { + return nil, fmt.Errorf("search one KEG with keg or several with kegs, not both: %w", keg.ErrInvalid) + } + kegs := make([]string, 0, len(o.Kegs)) + seen := make(map[string]bool, len(o.Kegs)) + for _, raw := range o.Kegs { + namespace, alias, err := ParseCanonicalKegRef(raw) + if err != nil { + return nil, err + } + ref := "@" + namespace + "/" + alias + if !seen[ref] { + seen[ref] = true + kegs = append(kegs, ref) + } + } + if len(kegs) > tapapi.SearchMaxKegs { + return nil, fmt.Errorf("search names %d KEGs; one search can name at most %d, so split the kegs across searches: %w", + len(kegs), tapapi.SearchMaxKegs, keg.ErrInvalid) + } + return kegs, nil +} + +// Search ranks nodes by relevance to opts.Query. The Hub scores, pages, and +// authorizes; the client only chooses the KEG scope. +func (t *Tap) Search(ctx context.Context, opts SearchOptions) (*tapapi.SearchResponse, error) { + kegs, err := opts.Validate() + if err != nil { + return nil, err + } + if opts.RequireBootstrap && !t.ConfigService.UserConfigExists() { + return nil, ErrNotBootstrapped + } + req := tapapi.SearchRequest{Query: opts.Query, Cursor: opts.Cursor, Limit: opts.Limit} + flight, err := t.searchFlight(ctx, opts.KegTargetOptions) + if err != nil { + return nil, err + } + + if strings.TrimSpace(opts.Keg) != "" { + return t.searchOneKeg(ctx, opts.KegTargetOptions, flight, req) + } + + scope := searchScope(kegs, flight) + if scope.restricted && len(scope.kegs) == 0 { + // An empty KEG list asks the Hub for every readable KEG, so a scope + // that narrowed to nothing must never reach it. + return &tapapi.SearchResponse{Hits: []tapapi.SearchHit{}, Warnings: scope.warnings}, nil + } + hubURL, token, err := t.resolveHubEndpoint(opts.Hub) + if err != nil { + return nil, err + } + req.Kegs = scope.kegs + if len(kegs) == 0 && flight != nil { + if hubScopesToCover(ctx, hubURL) { + // The orientation proof travels with this request, and the Hub + // scopes an unscoped governed search to the proof's flight cover + // itself. Naming the cover instead would trip the per-request KEG + // cap for a wide flight. + req.Kegs = nil + } else if len(req.Kegs) > tapapi.SearchMaxKegs { + return nil, fmt.Errorf("flight %s covers %d KEGs, more than the %d one search can name; narrow the search with kegs (--kegs): %w", + flight.Name, len(req.Kegs), tapapi.SearchMaxKegs, keg.ErrInvalid) + } + } + resp, err := postHubSearch(ctx, hubURL, token, req) + if err != nil { + return nil, err + } + resp.Warnings = append(scope.warnings, resp.Warnings...) + return resp, nil +} + +// searchOneKeg searches the single KEG target names. It resolves the KEG's Hub +// from configuration without opening the KEG: the Hub authorizes the KEG +// itself, and the client applies only the active flight's cover. +func (t *Tap) searchOneKeg(ctx context.Context, opts KegTargetOptions, flight *Flight, req tapapi.SearchRequest) (*tapapi.SearchResponse, error) { + target, err := t.ConfigService.ResolveTarget(strings.TrimSpace(opts.Keg), opts.Namespace, opts.Hub) + if err != nil { + return nil, err + } + ref := searchKegRef(target) + if strings.TrimSpace(target.HubURL) == "" || ref == "" { + return nil, fmt.Errorf("%w: %q is not a Hub KEG; use tap node grep (node_grep) for regex matching", ErrRankedSearchUnavailable, opts.Keg) + } + if err := t.enforceFlightTarget(flight, target, FlightRoleViewer); err != nil { + return nil, err + } + token := t.hubTokenForTarget(target) + if token == "" { + return nil, notLoggedInError(target.Hub, target.HubURL) + } + req.Kegs = []string{ref} + return postHubSearch(ctx, target.HubURL, token, req) +} + +// searchKegRef names a resolved target as "@namespace/alias": from its +// namespace and name, or for a KEG endpoint URL, from the Hub's per-KEG route +// /api/v1/@namespace/kegs/alias. It returns "" for anything else. +func searchKegRef(target *keg.Target) string { + if ref := CanonicalKegRef(target); ref != "" { + return ref + } + _, route, ok := strings.Cut(target.Url, "/api/v1/@") + if !ok { + return "" + } + parts := strings.Split(strings.Trim(route, "/"), "/") + if len(parts) != 3 || parts[1] != "kegs" { + return "" + } + namespace, alias, err := ParseCanonicalKegRef("@" + parts[0] + "/" + parts[2]) + if err != nil { + return "" + } + return "@" + namespace + "/" + alias +} + +// searchFlight returns the composed flight a search is restricted to, or nil: +// the call's resolved snapshot, else the named flight. +func (t *Tap) searchFlight(ctx context.Context, opts KegTargetOptions) (*Flight, error) { + if opts.BypassFlightRestrictions { + return nil, nil + } + if opts.FlightContext != nil { + return ComposedFlight(opts.FlightContext), nil + } + if strings.TrimSpace(opts.Flight) == "" || t.FlightService == nil { + return nil, nil + } + flight, err := t.FlightService.GetFlight(ctx, opts.Flight) + if err != nil { + return nil, err + } + return ComposedFlight(flight), nil +} + +// searchScopeResult is the KEG list a whole-Hub search sends. restricted marks +// a deliberate allowlist, where an empty list means "nothing", not +// "everything". +type searchScopeResult struct { + kegs []string + restricted bool + warnings []string +} + +// searchScope narrows explicit KEGs to the flight's cover, or without explicit +// KEGs, names the flight's cover. Both read the cover through searchCover, so +// a KEG is searchable on exactly the same terms either way. +func searchScope(explicit []string, flight *Flight) searchScopeResult { + if flight == nil { + return searchScopeResult{kegs: explicit, restricted: len(explicit) > 0} + } + cover, warnings := searchCover(flight) + out := searchScopeResult{kegs: []string{}, restricted: true, warnings: warnings} + if len(explicit) == 0 { + out.kegs = cover + return out + } + covered := make(map[string]bool, len(cover)) + for _, ref := range cover { + covered[ref] = true + } + for _, ref := range explicit { + if covered[ref] { + out.kegs = append(out.kegs, ref) + continue + } + out.warnings = append(out.warnings, fmt.Sprintf("%s is not in flight %s's cover and was not searched", ref, flight.Name)) + } + return out +} + +// searchCover lists, in cover order and once each, the "@namespace/alias" +// references of the flight's cover a search may read: the entries RoleFor +// evaluates, legacy AllowedKegs included, at viewer or above. Ranked search +// names KEGs canonically, so a cover entry without a namespace cannot be +// searched; it is reported as a warning rather than matched against any +// namespace. +func searchCover(flight *Flight) (refs, warnings []string) { + refs = []string{} + seen := map[string]bool{} + for _, c := range flight.coverEntries() { + if c.Namespace == "" { + warnings = append(warnings, fmt.Sprintf("cover entry %q names no namespace and was not searched", c.Keg)) + continue + } + if !normalizeFlightRole(c.Role).AtLeast(FlightRoleViewer) { + continue + } + ref := "@" + c.Namespace + "/" + c.Keg + if !seen[ref] { + seen[ref] = true + refs = append(refs, ref) + } + } + return refs, warnings +} + +// searchEndpoint is the Hub's TAP search route. +func searchEndpoint(hubURL string) (string, error) { + base, err := normalizeHubURL(hubURL) + if err != nil { + return "", err + } + return base + "/api/v1" + tapapi.PathSearch, nil +} + +// hubScopesToCover reports whether a search sent to hubURL carries the +// orientation proof, which makes the Hub scope an unscoped search to the +// proof's flight cover. +func hubScopesToCover(ctx context.Context, hubURL string) bool { + endpoint, err := searchEndpoint(hubURL) + if err != nil { + return false + } + _, ok := keg.OrientationHeaderForURL(ctx, endpoint) + return ok +} + +// postHubSearch sends one ranked-search request to the Hub's TAP search route +// with the caller's credentials and, inside a governed MCP call, its +// orientation proof. +func postHubSearch(ctx context.Context, hubURL, token string, in tapapi.SearchRequest) (*tapapi.SearchResponse, error) { + endpoint, err := searchEndpoint(hubURL) + if err != nil { + return nil, err + } + payload, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("hub: encode search request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("hub: build search request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if value, ok := keg.OrientationHeaderForURL(ctx, endpoint); ok { + req.Header.Set(keg.OrientationHeaderName, value) + } else if state, governed := keg.OrientationStateFromContext(ctx); governed && state.RootHub != "" { + // A governed call never searches a Hub its proof cannot reach: without + // the proof that Hub would answer at the identity's full authority. + return nil, fmt.Errorf("%w: search target is outside the pinned Hub", keg.ErrOrientationDenied) + } + + resp, err := apicontract.Do(hubHTTPClient(), req) + if err != nil { + return nil, fmt.Errorf("hub: contact hub: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, searchResponseLimit)) + if err != nil { + return nil, fmt.Errorf("hub: read search response: %w", err) + } + if resp.StatusCode != http.StatusOK { + routed := resp.Header.Get(tapapi.SearchRouteHeader) == tapapi.SearchRouteHeaderValue + return nil, searchError(resp.StatusCode, body, routed) + } + var out tapapi.SearchResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("hub: parse search response: %w", err) + } + if out.Hits == nil { + out.Hits = []tapapi.SearchHit{} + } + return &out, nil +} + +// searchError maps a failed search response to the shared sentinels. It is the +// only place search statuses are interpreted. +// +// routed reports the search route's tapapi.SearchRouteHeader. A Hub that +// predates ranked search has no route for it and answers 404 or 405 without +// the header, often with the same NOT_FOUND envelope the route uses for an +// unreadable KEG; only the header tells the two apart. +func searchError(status int, body []byte, routed bool) error { + var envelope struct { + Error string `json:"error"` + Code string `json:"code"` + } + _ = json.Unmarshal(body, &envelope) + missingRoute := !routed && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) + switch { + case missingRoute: + return fmt.Errorf("%w: this Hub does not offer ranked search; upgrade the Hub, or use tap node grep (node_grep) for regex matching", ErrRankedSearchUnavailable) + case status == http.StatusUnauthorized: + return fmt.Errorf("hub: %w", ErrTokenRejected) + } + msg := strings.TrimSpace(envelope.Error) + if msg == "" { + msg = fmt.Sprintf("search failed: %d %s", status, http.StatusText(status)) + } + return fmt.Errorf("hub: %w", keg.RemoteErrorFromCode(envelope.Code, status, msg)) +} + +// HighlightSnippet wraps each highlight span of snippet, given in rune +// offsets, in open and close markers. Spans are applied in order; a span that +// is empty, inverted, out of range, or overlaps an earlier one is clamped or +// skipped rather than trusted. +func HighlightSnippet(snippet string, spans []tapapi.Span, open, close string) string { + if len(spans) == 0 { + return snippet + } + runes := []rune(snippet) + var b strings.Builder + pos := 0 + for _, span := range spans { + start, end := max(span.Start, pos), min(span.End, len(runes)) + if start >= end { + continue + } + b.WriteString(string(runes[pos:start])) + b.WriteString(open) + b.WriteString(string(runes[start:end])) + b.WriteString(close) + pos = end + } + b.WriteString(string(runes[pos:])) + return b.String() +} + +// SearchHitLines renders one tab-separated line per hit: reference, score, +// title, and the snippet with its highlights wrapped in open and close. CLI and +// MCP share it so both surfaces show a hit the same way. +func SearchHitLines(hits []tapapi.SearchHit, open, close string) []string { + lines := make([]string, 0, len(hits)) + for _, hit := range hits { + lines = append(lines, strings.Join([]string{ + hit.Ref, + strconv.FormatFloat(hit.Score, 'f', 3, 64), + searchLineField(hit.Title), + searchLineField(HighlightSnippet(hit.Snippet, hit.Highlights, open, close)), + }, "\t")) + } + return lines +} + +// SearchNextCursorLine renders the line that hands a text reader the next +// page's cursor. The CLI prints it to stderr and node_search ends its text +// with it, so both surfaces show the cursor the same way. +func SearchNextCursorLine(cursor string) string { + return "next cursor: " + cursor +} + +// searchLineField keeps a value on one tab-separated line. +func searchLineField(value string) string { + return strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value)) +} diff --git a/pkg/tapper/tap_search_test.go b/pkg/tapper/tap_search_test.go new file mode 100644 index 0000000..3cb2f20 --- /dev/null +++ b/pkg/tapper/tap_search_test.go @@ -0,0 +1,553 @@ +package tapper_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapapi" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// fakeSearchHub records every ranked-search request body and answers with a +// fixed status and body. Like the Hub's search route, it marks its responses +// with tapapi.SearchRouteHeader unless unrouted is set, which mimics a Hub +// answering a route it does not have. +type fakeSearchHub struct { + mu sync.Mutex + bodies []string + proofs []string + calls atomic.Int32 + status int + reply string + unrouted bool +} + +func newFakeSearchHub(t *testing.T, status int, reply string) (*fakeSearchHub, string) { + t.Helper() + return newFakeSearchHubRouted(t, status, reply, true) +} + +func newFakeSearchHubRouted(t *testing.T, status int, reply string, routed bool) (*fakeSearchHub, string) { + t.Helper() + hub := &fakeSearchHub{status: status, reply: reply, unrouted: !routed} + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1"+tapapi.PathSearch || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + hub.calls.Add(1) + raw, _ := io.ReadAll(r.Body) + hub.mu.Lock() + hub.bodies = append(hub.bodies, string(raw)) + hub.proofs = append(hub.proofs, r.Header.Get(keg.OrientationHeaderName)) + hub.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if !hub.unrouted { + w.Header().Set(tapapi.SearchRouteHeader, tapapi.SearchRouteHeaderValue) + } + w.WriteHeader(hub.status) + _, _ = w.Write([]byte(hub.reply)) + })) + t.Cleanup(srv.Close) + return hub, srv.URL +} + +func (h *fakeSearchHub) last(t *testing.T) tapapi.SearchRequest { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + require.NotEmpty(t, h.bodies, "the Hub received no search request") + var got tapapi.SearchRequest + require.NoError(t, json.Unmarshal([]byte(h.bodies[len(h.bodies)-1]), &got)) + return got +} + +func (h *fakeSearchHub) lastProof(t *testing.T) string { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + require.NotEmpty(t, h.proofs, "the Hub received no search request") + return h.proofs[len(h.proofs)-1] +} + +func (h *fakeSearchHub) lastRaw(t *testing.T) map[string]any { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + require.NotEmpty(t, h.bodies) + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(h.bodies[len(h.bodies)-1]), &raw)) + return raw +} + +// newSearchTap returns a Tap whose default Hub is hubURL. +func newSearchTap(t *testing.T, hubURL string) *tapper.Tap { + t.Helper() + fx := NewSandbox(t) + require.NoError(t, fx.Setwd("/home/testuser")) + tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) + require.NoError(t, err) + config := fmt.Sprintf("hub: atlas\nhubs:\n atlas:\n url: %s\n token: tok\n", hubURL) + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + tap.ConfigService.Reload() + return tap +} + +func TestSearch_WholeHubPassesRequestThroughAndDecodesResponse(t *testing.T) { + t.Parallel() + + reply := `{"hits":[{"ref":"keg:@acme/notes/12","keg":"@acme/notes","node_id":"12","title":"Festival trip","score":4.25,` + + `"fields":["title","meta"],"sources":["keyword","semantic"],"snippet":"festival in Los Angeles","highlights":[{"start":12,"end":23}]}],` + + `"next_cursor":"c2","terms":[{"term":"angelos","lexemes":["angel"],"typo":true}],"mode":"hybrid","warnings":["partial"]}` + hub, url := newFakeSearchHub(t, http.StatusOK, reply) + tap := newSearchTap(t, url) + + cursor := " opaque/cursor+== " + resp, err := tap.Search(t.Context(), tapper.SearchOptions{Query: "los angelos", Cursor: cursor, Limit: 5}) + require.NoError(t, err) + + require.Equal(t, tapapi.SearchRequest{Query: "los angelos", Cursor: cursor, Limit: 5}, hub.last(t), + "a whole-Hub search names no KEG and passes the cursor through unchanged") + require.Len(t, resp.Hits, 1) + hit := resp.Hits[0] + require.Equal(t, "keg:@acme/notes/12", hit.Ref) + require.Equal(t, "@acme/notes", hit.Keg) + require.Equal(t, "12", hit.NodeID) + require.Equal(t, 4.25, hit.Score) + require.Equal(t, []string{tapapi.SearchFieldTitle, tapapi.SearchFieldMeta}, hit.Fields) + require.Equal(t, []string{tapapi.SearchSourceKeyword, tapapi.SearchSourceSemantic}, hit.Sources) + require.Equal(t, []tapapi.Span{{Start: 12, End: 23}}, hit.Highlights) + require.Equal(t, "c2", resp.NextCursor) + require.Equal(t, tapapi.SearchModeHybrid, resp.Mode) + require.Equal(t, []string{"partial"}, resp.Warnings) + require.Equal(t, []tapapi.TermExpansion{{Term: "angelos", Lexemes: []string{"angel"}, Typo: true}}, resp.Terms) +} + +func TestSearch_OmittedLimitLetsTheHubChoose(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"mode":"keyword"}`) + tap := newSearchTap(t, url) + + resp, err := tap.Search(t.Context(), tapper.SearchOptions{Query: "release"}) + require.NoError(t, err) + raw := hub.lastRaw(t) + for _, key := range []string{"limit", "cursor", "kegs"} { + _, present := raw[key] + require.False(t, present, "%s must be omitted so the Hub applies its own default", key) + } + require.NotNil(t, resp.Hits, "a response without hits decodes to an empty list") + require.Empty(t, resp.Hits) + require.Equal(t, tapapi.SearchModeKeyword, resp.Mode) +} + +func TestSearch_NamedKegsNarrowTheSearch(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + _, err := tap.Search(t.Context(), tapper.SearchOptions{Query: "release", Kegs: []string{"@acme/notes", " @acme/eng ", "@acme/notes"}}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/notes", "@acme/eng"}, hub.last(t).Kegs) +} + +func TestSearch_RejectsInvalidRequestsBeforeTheHub(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + cases := map[string]tapper.SearchOptions{ + "empty query": {Query: " "}, + "negative limit": {Query: "x", Limit: -1}, + "bare keg ref": {Query: "x", Kegs: []string{"notes"}}, + "keg and kegs": {Query: "x", KegTargetOptions: tapper.KegTargetOptions{Keg: "@acme/notes"}, Kegs: []string{"@acme/eng"}}, + } + for name, opts := range cases { + _, err := tap.Search(t.Context(), opts) + require.ErrorIs(t, err, keg.ErrInvalid, name) + } + require.Zero(t, hub.calls.Load()) +} + +func TestSearch_FlightCoverScopesTheSearch(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + flight := &tapper.Flight{Name: "@acme/+work", EffectiveCover: []tapper.HubEffectiveKeg{ + {Ref: "@acme/notes", Role: tapper.FlightRoleViewer}, + {Ref: "@acme/eng", Role: tapper.FlightRoleEditor}, + }} + inFlight := tapper.KegTargetOptions{FlightContext: flight} + + _, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/notes", "@acme/eng"}, hub.last(t).Kegs) + + // A named KEG outside the cover is dropped with a warning. + resp, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Kegs: []string{"@acme/eng", "@other/private"}, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/eng"}, hub.last(t).Kegs) + require.Len(t, resp.Warnings, 1) + require.Contains(t, resp.Warnings[0], "@other/private") + + // The direct CLI bypasses the flight and searches what it names. + _, err = tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{FlightContext: flight, BypassFlightRestrictions: true}, + Kegs: []string{"@other/private"}, Query: "release", + }) + require.NoError(t, err) + require.Equal(t, []string{"@other/private"}, hub.last(t).Kegs) +} + +// wideCoverFlight returns a flight whose cover is wider than one search can +// name. +func wideCoverFlight(n int) (*tapper.Flight, []string) { + flight := &tapper.Flight{Name: "@acme/+wide", EffectiveCover: []tapper.HubEffectiveKeg{}} + refs := make([]string, 0, n) + for i := range n { + ref := fmt.Sprintf("@acme/k%02d", i) + refs = append(refs, ref) + flight.EffectiveCover = append(flight.EffectiveCover, tapper.HubEffectiveKeg{Ref: ref, Role: tapper.FlightRoleViewer}) + } + return flight, refs +} + +func TestSearch_GovernedUnscopedSearchLetsTheHubScopeToTheCover(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + flight, refs := wideCoverFlight(tapapi.SearchMaxKegs + 10) + ctx := keg.WithOrientationState(t.Context(), keg.OrientationState{RootHub: url, Root: "@acme/+root", Active: "@acme/+wide"}) + inFlight := tapper.KegTargetOptions{FlightContext: flight} + + _, err := tap.Search(ctx, tapper.SearchOptions{KegTargetOptions: inFlight, Query: "release"}) + require.NoError(t, err) + _, named := hub.lastRaw(t)["kegs"] + require.False(t, named, "with the orientation proof attached, the Hub scopes an unscoped search to the cover") + require.NotEmpty(t, hub.lastProof(t), "the cover is only implied by the proof, so the proof must travel") + + // Explicit kegs still narrow, and still only within the cover. + resp, err := tap.Search(ctx, tapper.SearchOptions{KegTargetOptions: inFlight, Kegs: []string{refs[3], "@other/private"}, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{refs[3]}, hub.last(t).Kegs) + require.Contains(t, strings.Join(resp.Warnings, "\n"), "@other/private") + + // A governed flight that covers nothing still never reaches the Hub. + before := hub.calls.Load() + empty := tapper.KegTargetOptions{FlightContext: &tapper.Flight{Name: "@acme/+empty", EffectiveCover: []tapper.HubEffectiveKeg{}}} + resp, err = tap.Search(ctx, tapper.SearchOptions{KegTargetOptions: empty, Query: "release"}) + require.NoError(t, err) + require.Empty(t, resp.Hits) + require.Equal(t, before, hub.calls.Load()) +} + +func TestSearch_TooManyKegsAreRefusedBeforeTheHub(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + flight, refs := wideCoverFlight(tapapi.SearchMaxKegs + 1) + + // Without the orientation proof the client names the cover, so a cover + // wider than the Hub's cap is refused clearly instead of with a Hub 400. + _, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: tapper.KegTargetOptions{FlightContext: flight}, Query: "release"}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "flight @acme/+wide covers 51 KEGs") + require.ErrorContains(t, err, "--kegs") + + // Naming more KEGs than one search can carry is refused on every path. + governed := keg.WithOrientationState(t.Context(), keg.OrientationState{RootHub: url, Root: "@acme/+root", Active: "@acme/+wide"}) + _, err = tap.Search(governed, tapper.SearchOptions{KegTargetOptions: tapper.KegTargetOptions{FlightContext: flight}, Kegs: refs, Query: "release"}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "at most 50") + require.Zero(t, hub.calls.Load()) + + // Narrowing a wide cover with explicit kegs works. + _, err = tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: tapper.KegTargetOptions{FlightContext: flight}, Kegs: refs[:2], Query: "release"}) + require.NoError(t, err) + require.Equal(t, refs[:2], hub.last(t).Kegs) + require.Empty(t, hub.lastProof(t)) +} + +func TestSearch_ExplicitKegsNeedANamespacedCoverEntry(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + flight := &tapper.Flight{Name: "@acme/+work", FlightManifest: tapper.FlightManifest{Cover: []tapper.FlightCover{ + {Keg: "notes", Role: tapper.FlightRoleViewer}, + {Namespace: "acme", Keg: "eng", Role: tapper.FlightRoleViewer}, + }}} + inFlight := tapper.KegTargetOptions{FlightContext: flight} + + // A namespace-less cover entry matches no namespace, explicit or implicit. + resp, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Kegs: []string{"@other/notes", "@acme/eng"}, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/eng"}, hub.last(t).Kegs) + require.Contains(t, strings.Join(resp.Warnings, "\n"), "@other/notes is not in flight") + + resp, err = tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/eng"}, hub.last(t).Kegs) + require.Contains(t, strings.Join(resp.Warnings, "\n"), `cover entry "notes" names no namespace`) +} + +func TestSearch_LegacyAllowedKegsScopeBothPaths(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + flight := &tapper.Flight{Name: "@acme/+legacy", FlightManifest: tapper.FlightManifest{ + AllowedKegs: []string{"@acme/notes", "@acme/eng=viewer", "bare"}, + }} + inFlight := tapper.KegTargetOptions{FlightContext: flight} + + _, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/notes", "@acme/eng"}, hub.last(t).Kegs, "an unscoped search covers the legacy allowlist") + + _, err = tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: inFlight, Kegs: []string{"@acme/eng", "@other/bare"}, Query: "release"}) + require.NoError(t, err) + require.Equal(t, []string{"@acme/eng"}, hub.last(t).Kegs, "an explicit search honors the same allowlist") +} + +func TestSearch_EmptyRestrictedScopeNeverReachesTheHub(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + empty := tapper.KegTargetOptions{FlightContext: &tapper.Flight{Name: "@acme/+empty", EffectiveCover: []tapper.HubEffectiveKeg{}}} + resp, err := tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: empty, Query: "release"}) + require.NoError(t, err) + require.NotNil(t, resp.Hits) + require.Empty(t, resp.Hits) + + narrow := tapper.KegTargetOptions{FlightContext: &tapper.Flight{Name: "@acme/+work", EffectiveCover: []tapper.HubEffectiveKeg{{Ref: "@acme/notes", Role: tapper.FlightRoleViewer}}}} + resp, err = tap.Search(t.Context(), tapper.SearchOptions{KegTargetOptions: narrow, Kegs: []string{"@other/private"}, Query: "release"}) + require.NoError(t, err) + require.Empty(t, resp.Hits) + require.NotEmpty(t, resp.Warnings) + + require.Zero(t, hub.calls.Load(), "an empty KEG list means everything to the Hub, so a scope narrowed to nothing must not be sent") +} + +func TestSearch_OneKegNamesOnlyThatKeg(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + _, err := tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{Keg: "@acme/notes"}, + Query: "release", Cursor: "c1", Limit: 3, + }) + require.NoError(t, err) + require.Equal(t, tapapi.SearchRequest{Query: "release", Kegs: []string{"@acme/notes"}, Cursor: "c1", Limit: 3}, hub.last(t)) +} + +func TestSearch_OneKegOutsideTheFlightIsRefused(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + flight := &tapper.Flight{Name: "@acme/+work", EffectiveCover: []tapper.HubEffectiveKeg{{Ref: "@acme/notes", Role: tapper.FlightRoleViewer}}} + _, err := tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{Keg: "@acme/private", FlightContext: flight}, + Query: "release", + }) + var restriction *tapper.FlightRestrictionError + require.ErrorAs(t, err, &restriction) + require.Zero(t, hub.calls.Load(), "a KEG outside the cover must not reach the Hub") + + _, err = tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{Keg: "@acme/notes", FlightContext: flight}, + Query: "release", + }) + require.NoError(t, err) + require.Equal(t, []string{"@acme/notes"}, hub.last(t).Kegs) +} + +func TestSearch_EndpointKegTargets(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + // A KEG endpoint on the selected Hub is searched by its canonical ref. + _, err := tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{Keg: url + "/api/v1/@acme/kegs/notes"}, + Query: "release", + }) + require.NoError(t, err) + require.Equal(t, []string{"@acme/notes"}, hub.last(t).Kegs) + + // An endpoint that names no namespaced KEG cannot be ranked. + before := hub.calls.Load() + _, err = tap.Search(t.Context(), tapper.SearchOptions{ + KegTargetOptions: tapper.KegTargetOptions{Keg: url + "/api/v1/notes"}, + Query: "release", + }) + require.ErrorIs(t, err, tapper.ErrRankedSearchUnavailable) + require.ErrorContains(t, err, "tap node grep") + require.Equal(t, before, hub.calls.Load()) +} + +func TestSearch_GovernedCallRefusesAnotherHub(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + tap := newSearchTap(t, url) + + ctx := keg.WithOrientationState(t.Context(), keg.OrientationState{ + RootHub: "https://pinned.example.invalid", Root: "@acme/+root", Active: "@acme/+root", + }) + _, err := tap.Search(ctx, tapper.SearchOptions{Query: "release"}) + require.ErrorIs(t, err, keg.ErrOrientationDenied) + require.Zero(t, hub.calls.Load()) +} + +func TestSearch_HubErrorsMapToSentinels(t *testing.T) { + t.Parallel() + + envelope := func(code string) string { + raw, _ := json.Marshal(map[string]string{"error": "refused", "code": code}) + return string(raw) + } + cases := []struct { + name string + status int + body string + wholeHub bool + routed bool + want error + }{ + {"405 without the route header is a missing route", http.StatusMethodNotAllowed, "", false, false, tapper.ErrRankedSearchUnavailable}, + {"bare 404 without the route header is a missing route", http.StatusNotFound, "404 page not found", false, false, tapper.ErrRankedSearchUnavailable}, + // Hubs answer unknown /api/v1 routes with an enveloped NOT_FOUND, the + // same body the route uses for an unreadable KEG. + {"enveloped scoped 404 without the route header is a missing route", http.StatusNotFound, envelope(keg.RemoteCodeNotFound), false, false, tapper.ErrRankedSearchUnavailable}, + {"enveloped whole-Hub 404 without the route header is a missing route", http.StatusNotFound, envelope(keg.RemoteCodeNotFound), true, false, tapper.ErrRankedSearchUnavailable}, + {"routed 404 for a named KEG", http.StatusNotFound, envelope(keg.RemoteCodeNotFound), false, true, keg.ErrNotExist}, + {"routed whole-Hub 404", http.StatusNotFound, envelope(keg.RemoteCodeNotFound), true, true, keg.ErrNotExist}, + {"routed 405", http.StatusMethodNotAllowed, envelope(keg.RemoteCodeBadRequest), false, true, nil}, + {"401", http.StatusUnauthorized, envelope("UNAUTHORIZED"), false, true, tapper.ErrTokenRejected}, + {"403", http.StatusForbidden, envelope("FORBIDDEN"), false, true, keg.ErrForbidden}, + {"400", http.StatusBadRequest, envelope(keg.RemoteCodeInvalid), false, true, keg.ErrInvalid}, + {"409 version", http.StatusConflict, envelope(keg.RemoteCodeKegVersionUnsupported), false, true, keg.ErrKegVersionUnsupported}, + {"429", http.StatusTooManyRequests, envelope("RATE_LIMITED"), false, true, nil}, + {"500", http.StatusInternalServerError, "", false, true, nil}, + {"unrouted 500 is not a missing route", http.StatusInternalServerError, "", false, false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, url := newFakeSearchHubRouted(t, tc.status, tc.body, tc.routed) + tap := newSearchTap(t, url) + opts := tapper.SearchOptions{Query: "release"} + if !tc.wholeHub { + opts.Kegs = []string{"@acme/notes"} + } + _, err := tap.Search(t.Context(), opts) + require.Error(t, err) + if tc.want != nil { + require.ErrorIs(t, err, tc.want) + } + if tc.want != tapper.ErrRankedSearchUnavailable { + require.NotErrorIs(t, err, tapper.ErrRankedSearchUnavailable) + } + }) + } +} + +func TestSearch_UnavailableIsNotSupported(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, tapper.ErrRankedSearchUnavailable, keg.ErrNotSupported) + _, url := newFakeSearchHubRouted(t, http.StatusNotFound, "404 page not found", false) + _, err := newSearchTap(t, url).Search(t.Context(), tapper.SearchOptions{Query: "release"}) + require.ErrorIs(t, err, tapper.ErrRankedSearchUnavailable) + code, _ := keg.RemoteErrorCode(err) + require.Equal(t, keg.RemoteCodeNotSupported, code, "a Hub without ranked search is an unsupported operation, not an unknown failure") + require.ErrorContains(t, err, "node_grep") +} + +func TestSearch_MissingHubOrLoginMapToCodes(t *testing.T) { + t.Parallel() + + hub, url := newFakeSearchHub(t, http.StatusOK, `{"hits":[]}`) + fx := NewSandbox(t) + require.NoError(t, fx.Setwd("/home/testuser")) + tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) + require.NoError(t, err) + config := fmt.Sprintf("hub: atlas\nhubs:\n atlas:\n url: %s\n", url) + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + tap.ConfigService.Reload() + + for name, opts := range map[string]tapper.SearchOptions{ + "whole hub": {Query: "release"}, + "one keg": {Query: "release", KegTargetOptions: tapper.KegTargetOptions{Keg: "@acme/notes"}}, + } { + _, err := tap.Search(t.Context(), opts) + require.ErrorIs(t, err, keg.ErrUnauthorized, name) + require.ErrorContains(t, err, "tap auth login", name) + } + + _, err = tap.Search(t.Context(), tapper.SearchOptions{Query: "release", KegTargetOptions: tapper.KegTargetOptions{Hub: "nowhere"}}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, `hub "nowhere" is not configured`) + require.Zero(t, hub.calls.Load()) +} + +func TestHighlightSnippet(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + snippet string + spans []tapapi.Span + want string + }{ + {"one span", "a bc d", []tapapi.Span{{Start: 2, End: 4}}, "a [bc] d"}, + {"no spans", "plain", nil, "plain"}, + {"rune offsets", "é ñ", []tapapi.Span{{Start: 2, End: 3}}, "é [ñ]"}, + {"overlap skipped", "abc", []tapapi.Span{{Start: 0, End: 2}, {Start: 1, End: 2}}, "[ab]c"}, + {"overlap clamped", "abcd", []tapapi.Span{{Start: 0, End: 2}, {Start: 1, End: 3}}, "[ab][c]d"}, + {"end clamped", "abc", []tapapi.Span{{Start: 1, End: 9}}, "a[bc]"}, + {"out of range skipped", "abc", []tapapi.Span{{Start: 5, End: 9}}, "abc"}, + {"negative start clamped", "abc", []tapapi.Span{{Start: -3, End: 1}}, "[a]bc"}, + {"inverted skipped", "abc", []tapapi.Span{{Start: 2, End: 1}}, "abc"}, + {"empty skipped", "abc", []tapapi.Span{{Start: 1, End: 1}}, "abc"}, + } + for _, tc := range cases { + require.Equal(t, tc.want, tapper.HighlightSnippet(tc.snippet, tc.spans, "[", "]"), tc.name) + } +} + +func TestSearchHitLines(t *testing.T) { + t.Parallel() + + lines := tapper.SearchHitLines([]tapapi.SearchHit{ + {Ref: "keg:@acme/notes/12", Title: "Trip\tplan", Score: 4.25, Snippet: "festival in\nLos Angeles", Highlights: []tapapi.Span{{Start: 0, End: 8}}}, + {Ref: "keg:@acme/eng/3", Title: "Release", Score: 1}, + }, "**", "**") + require.Equal(t, []string{ + "keg:@acme/notes/12\t4.250\tTrip plan\t**festival** in Los Angeles", + "keg:@acme/eng/3\t1.000\tRelease\t", + }, lines) +}