diff --git a/README.md b/README.md index 8c9691bf..4570fcab 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,17 @@ Move a repository or session onto the right shared memory: ```bash tap use @acme/engineering tap use @acme/engineering --flight @acme/+release-42 +tap info +tap url 42 --open ``` +`tap info` shows the hub you are signed in to, the default keg, the active +flight, and the kegs that flight covers. `tap url [REF]` prints the hub web +link for the current keg, a node (`42`, `@acme/engineering/42`), a flight +(`+release-42`), or a namespace (`@acme`); `--open` also opens it in the +browser. Agents get the same link from the MCP `url` tool. `tap explore kegs|flights|people QUERY` +searches the hub's public directory; your own kegs come from `tap keg list`. + Export a keg for backup or migration: ```bash diff --git a/docs/README.md b/docs/README.md index 4130350e..60b59f4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -133,7 +133,17 @@ writing KEG files directly. 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 info` - show the hub and signed-in user, the default keg, the active + flight, and the kegs that flight covers. +- `tap url [REF]` - print the hub web link for the current keg, a node (`42`, + `@namespace/keg/42`, `keg:~alias/42`), a keg, a flight (`+slug`), or a + namespace (`@namespace`). `--open` also opens it in the browser and `--json` + adds the kind and canonical reference. MCP's `url` tool returns the same + link. +- `tap keg list` - list your kegs on a hub. The list is complete; search is + only for the public directory. +- `tap explore kegs|flights|people QUERY` - search the hub's public directory. + A match grants no access. - `tap keg grant|grants|revoke` - manage per-keg access. - `tap keg visibility public|private` - set keg visibility. - `tap keg rename @namespace/old new` - rename a keg alias in its namespace. diff --git a/docs/ai-coding-agents/agent-conventions.md b/docs/ai-coding-agents/agent-conventions.md index dc16a35b..59d5ee06 100644 --- a/docs/ai-coding-agents/agent-conventions.md +++ b/docs/ai-coding-agents/agent-conventions.md @@ -107,8 +107,9 @@ Tapper recognizes two link forms in node bodies: - **Intra-keg:** `[title](../NODEID)` — relative path from the current node's directory to the target node's directory. - **Cross-keg:** `keg:@namespace/keg/NODEID` — fully qualified reference that - the index parses into a cross-keg edge. Bare names can also be used when the - active config resolves them unambiguously. + the index parses into a cross-keg edge; on a Hub a flight's cover follows it. + `keg:@namespace/keg/0` links a KEG's root. The `@` is required. Bare names can + also be used when the active config resolves them unambiguously. Both forms appear in backlinks. Prefer intra-keg links when the target is in the same keg — they resolve in any markdown renderer. diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index 9c56f362..d102de2e 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -77,6 +77,7 @@ With a default keg: Tool names are `_` (`node_read`, `flight_list`, `agent_edit`), and each matches the CLI command `tap `. +`orient` and `url` are single words, matching `tap orient` and `tap url`. The MCP server exposes a shared agent surface rather than every machine-local CLI capability. Exact tool availability follows the installed Tapper version @@ -105,6 +106,7 @@ explicitly configured root failed to initialize. | `keg_info` | Show concise diagnostics for the resolved keg | | `keg_settings_read` | Read targeted minimal config for one or more selected KEGs; `minimal=false` reads one complete config | | `node_stats` | Show node statistics | +| `url` | Hub web link for a KEG, note, flight, or namespace, to hand the reader a clickable link | ### Write @@ -192,14 +194,22 @@ vocabulary to ask. ### Discovery And Identity -| Tool | Description | -| --- | --- | -| `keg_list` | List canonical KEGs, effective roles, and winning granting flights for the live pinned-root graph by default or exactly one supplied `flight` | -| `keg_search` | Search all identity-accessible KEG refs, titles, and summaries, including KEGs outside the flight graph; results grant no operational access | -| `session_info` | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | -| `namespace_list` | List the namespaces you belong to, with your role | -| `namespace_search` | Search the people and org namespaces visible on the Hub; discovery only | -| `invitation_list` | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision | +Every tool belongs to one category, and its description opens with the +category name: **Session** (identity and what the session may do; always +available), **Memory** (reading and writing notes in the covered KEGs), +**Explore** (the Hub's public directory), or **Manage** (KEGs, settings, +schemas, repair, flights, and agents). Your own resources are listed, never +searched; search is for note content (`node_search`) and the public directory. + +| Tool | Category | Description | +| --- | --- | --- | +| `keg_list` | Memory | List canonical KEGs with effective roles, titles, descriptions, and winning granting flights for the live pinned-root graph by default or exactly one supplied `flight` | +| `session_info` | Session | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | +| `namespace_list` | Session | List the namespaces you belong to, with your role | +| `invitation_list` | Session | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision | +| `explore_kegs` | Explore | Search the Hub's public KEGs; results grant no operational access | +| `explore_flights` | Explore | Search the Hub's public flights; metadata only | +| `explore_people` | Explore | Search the people and org namespaces on the Hub; discovery only | Each identity includes only its hub locator, user ID, username, display name, default namespace, and namespace names. Tokens, email, scopes, cookies, expiry, diff --git a/docs/ai-coding-agents/orient.md b/docs/ai-coding-agents/orient.md index 12cb0bd9..e42243ae 100644 --- a/docs/ai-coding-agents/orient.md +++ b/docs/ai-coding-agents/orient.md @@ -56,8 +56,8 @@ instructions: | Snapshot any node before changing public API guidance. ``` -`summary` is the concise discovery description shown by graph orientation and -identity `keg_search`. +`summary` is the concise discovery description shown by graph orientation, +`keg_list`, and, for a public KEG, `explore_kegs`. It should help an agent decide whether the KEG is relevant and is not automatically truncated. `instructions` is targeted operational guidance. Aggregate orientation never includes it. @@ -117,11 +117,11 @@ access requires reconnecting. A failed refresh returns `SESSION_REFRESH_FAILED`, keeps the prior mode and tool surface, and reports `toolsChanged:false`. -`keg_search` is deliberately outside this authority flow. It performs a -case-insensitive literal match over canonical ref, title, and summary for all -identity-accessible KEGs and returns at most 50 canonical rows. Finding a KEG -does not change authority: no-flight calls still use the identity role, while a -real-flight call must also cover it. +`explore_kegs`, `explore_flights`, and `explore_people` are deliberately +outside this authority flow. They search only the Hub's public directory with a +literal, non-empty query and return a capped page. Finding a KEG does not change +authority: a real-flight call must still cover it. Your own KEGs come from +`keg_list`, which is complete and needs no query. ## MCP Resource diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md index e6b23e4a..43503ad7 100644 --- a/docs/api-compatibility.md +++ b/docs/api-compatibility.md @@ -1,7 +1,7 @@ # REST API contract compatibility Tapper and Tapper Hub select a REST contract independently of their release -numbers. The sole supported revision is `2026-09-28`, which replaced `2026-09-11` when grep, listing, and relationship responses gained server-side projection. +numbers. The sole supported revision is `2026-10-12`, which replaced `2026-09-28` when `POST /api/v1/namespaces/search` was removed in favor of `POST /api/v1/explore`, the public directory of KEGs, flights, and people. `2026-09-28` had replaced `2026-09-11` when grep, listing, and relationship responses gained server-side projection. Change the revision for incompatible API behavior. Compatible additions and fixes retain the revision; release numbers remain diagnostic metadata. @@ -9,11 +9,11 @@ fixes retain the revision; release numbers remain diagnostic metadata. returns `Cache-Control: no-store` with the actual server build version: ```json -{"server_version":"0.24.0","api_versions":["2026-09-28"]} +{"server_version":"0.24.0","api_versions":["2026-10-12"]} ``` Every request under `/api/v1`, including event-stream handshakes and telemetry, -must send exactly one `Tapper-API-Version: 2026-09-28` header. Accepted requests +must send exactly one `Tapper-API-Version: 2026-10-12` header. Accepted requests echo it in the response. Clients send their release in `Tapper-Client-Version`. The Hub validates the contract before authentication, flight authority, or application handlers. Discovery, health, OAuth, browser routes, and hosted @@ -26,7 +26,7 @@ duplicate, comma-joined, or unsupported values return HTTP 400 with `operationPerformed: false`, for example: ```json -{"error":"unsupported REST contract; upgrade Tapper and Hub together","code":"API_VERSION_UNSUPPORTED","requested_api_versions":["2099-01-01"],"supported_api_versions":["2026-09-28"],"server_version":"0.24.0","operationPerformed":false} +{"error":"unsupported REST contract; upgrade Tapper and Hub together","code":"API_VERSION_UNSUPPORTED","requested_api_versions":["2099-01-01"],"supported_api_versions":["2026-10-12"],"server_version":"0.24.0","operationPerformed":false} ``` CLI invocations and MCP connections discover each selected Hub once, before @@ -57,7 +57,7 @@ working against the new Hub, and new clients reject legacy Hubs. Update custom scripts explicitly, for example: ```sh -curl -H 'Tapper-API-Version: 2026-09-28' \ +curl -H 'Tapper-API-Version: 2026-10-12' \ -H "Authorization: Bearer ${TAPPER_TOKEN}" \ https://hub.example.com/api/v1/whoami ``` diff --git a/docs/architecture/configuration-resolution.md b/docs/architecture/configuration-resolution.md index f5186094..ba97f775 100644 --- a/docs/architecture/configuration-resolution.md +++ b/docs/architecture/configuration-resolution.md @@ -92,6 +92,32 @@ KEG settings are separate from Tapper user/project config — different schema, different purpose (KEG metadata vs resolver settings) — and are read or written through the Hub. +## Hub Web Links + +`tap url` and the MCP `url` tool (`Tap.WebURL`, `pkg/tapper/tap_url.go`) turn a +reference into a link to the Hub's web pages. Resolution reuses the keg +resolution above: a keg or node reference resolves to its target, whose +configured hub URL is the link's origin. Flights and namespaces use the +selected hub. Only a keg-local `keg:~alias` reads the current keg's settings, +so the command skips the startup credential refresh. A Hub serving its own MCP +sets `Tap.WebBaseURL` to its public address and resolves kegs through its +injected `KegResolver`. + +The links depend on four Hub browser routes. They are outside the Hub's REST +contract (see [API Compatibility](../api-compatibility.md)), so a Hub that +moves them breaks these links: + +| Page | Path | +| --- | --- | +| Namespace | `/@namespace` | +| KEG | `/@namespace/keg` | +| Note | `/@namespace/keg/42` (plain decimal node id, no code) | +| Flight | `/@namespace/+slug` | + +Every namespace, keg, and slug segment matches `^[a-z0-9_-]+$`, so paths are +never escaped. A raw HTTP(S) keg endpoint that no configured hub serves has no +web page and is refused. + ## Read Next - [Resolution Order](../configuration/resolution-order.md) diff --git a/docs/configuration/flights.md b/docs/configuration/flights.md index 384ad0e1..efaf6059 100644 --- a/docs/configuration/flights.md +++ b/docs/configuration/flights.md @@ -155,7 +155,7 @@ on the agent's page on Hub. - Recovery-only mode applies only when an explicitly configured root is missing, inaccessible, invalid, or unavailable. Seeing only `orient`, `session_refresh`, `flight_list`, `flight_read`, `session_info`, and - `keg_search` means configured authority failed to initialize. Repair the + `guide` means configured authority failed to initialize. Repair the configured root outside MCP, then call `session_refresh` and `orient`. - Every MCP connection pins either no-flight authority or one real root at initialization. Configuration and account-preference changes cannot change diff --git a/docs/development/gotchas.md b/docs/development/gotchas.md index 9a6c6923..5e8da539 100644 --- a/docs/development/gotchas.md +++ b/docs/development/gotchas.md @@ -32,15 +32,17 @@ Behavior that has surprised people before. KEG metadata uses `description`; legacy settings/archive `summary` is accepted only when description is absent. Flight descriptions flow through manifests, REST/MCP, editing, and hashes independently of instructions and authority. -`orient` renders full active instructions, the complete active effective KEG cover; child flights require explicit inspection. No-flight orientation is search-first. `flight_search` returns -at most 50 ordered metadata rows with a truncation notice; `guide` serves detailed +`orient` renders full active instructions, the complete active effective KEG cover; child flights require explicit inspection. No-flight orientation points to the complete `keg_list` and `flight_list`, and to the `explore_*` tools for the public directory, which return a capped page with a truncation notice; `guide` serves detailed canonical guidance by topic. Display descriptions never affect authority revisions. The coordinated REST/MCP replacement is in progress, not complete. Cover entries -now carry depth (default 2, range 1–8). The Hub composes authority through declared -same-Hub settings relationships. Flights carry no capabilities; an agent's tools decide what a session may do. -`keg:~alias/node` explicitly resolves the source KEG's settings alias; canonical -references keep their meaning. Orientation request transport requires a root and +now carry depth (default 2, range 1–8). The Hub composes a flight's cover from +its listed KEGs and the same-Hub KEGs their notes link to; a cover entry with +role `none` blocks a KEG however it is reached. Flights carry no capabilities; +an agent's tools decide what a session may do. `keg:~alias/node` explicitly +resolves the source KEG's settings alias (settings `aliases`, formerly `links`, +which is still read when `aliases` is absent); canonical references keep their +meaning. Orientation request transport requires a root and active flight, with no revision acknowledgment requirement. The hub publishes its own verification status for the replacement's scope and gaps. diff --git a/integrations/content/agent-orient.md b/integrations/content/agent-orient.md index c0125680..f4cbd83d 100644 --- a/integrations/content/agent-orient.md +++ b/integrations/content/agent-orient.md @@ -14,8 +14,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -25,7 +26,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. diff --git a/integrations/content/linking.md b/integrations/content/linking.md index 682ad36b..fc9d5f64 100644 --- a/integrations/content/linking.md +++ b/integrations/content/linking.md @@ -8,11 +8,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/content/snapshot-policy.md b/integrations/content/snapshot-policy.md index 7bdf37b7..3dc58870 100644 --- a/integrations/content/snapshot-policy.md +++ b/integrations/content/snapshot-policy.md @@ -6,12 +6,19 @@ are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -41,4 +48,5 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 855d6d8e..ef24e1a7 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -1,12 +1,28 @@ # Tool inventory +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -31,27 +47,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `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__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, 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`. 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. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -64,7 +88,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `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. | 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 @@ -97,19 +120,22 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -163,7 +189,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `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. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -175,31 +223,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -223,31 +258,36 @@ 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. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index 8795ee10..fdc91912 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `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__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, 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`. 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. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `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. | 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 @@ -141,19 +165,22 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +234,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `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. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +268,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +303,36 @@ 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. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. @@ -321,12 +362,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -356,7 +404,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: @@ -366,11 +415,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index 8795ee10..fdc91912 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `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__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, 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`. 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. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `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. | 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 @@ -141,19 +165,22 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +234,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `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. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +268,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +303,36 @@ 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. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. @@ -321,12 +362,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -356,7 +404,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: @@ -366,11 +415,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index 8795ee10..fdc91912 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -19,8 +19,9 @@ flight on subsequent operations. keg selects a target and never grants access. orient returns current active instructions, effective readable KEGs, and immediate readable children. Orient with a child reference to inspect the next -level. With no root, orientation is search-first; keg_search and flight_search -find identity-readable metadata. Discovery is independent of selection. +level. With no root, keg_list and flight_list are the complete lists of what +your identity reads, and explore_kegs and explore_flights search the public +directory. Discovery is independent of selection. flight_read inspects a permission-filtered declared manifest and its hash; it never activates a session or selects operational authority. @@ -30,7 +31,7 @@ MCP and starts a fresh connection. session_refresh only retries failed initial activation; it cannot change an active connection. A failed configured root exposes recovery tools: orient, session_refresh, -flight_list, flight_read, session_info, keg_search, flight_search, guide. Repair +flight_list, flight_read, session_info, guide. Repair the configured root outside MCP, then retry session_refresh and orient. An active flight can have no effective KEGs and still expose the full inventory; KEG denials in that scope do not imply missing tools. @@ -44,13 +45,29 @@ If orient is unavailable, report that the Tapper MCP connection is unavailable and ask the user to reconnect or restart the host session. Never kill or signal host-owned processes. +Every tool belongs to one category, and its description opens with the +category name: + +- **Session**: who you are and what this session may do. Always available. +- **Memory**: read and write notes in the KEGs this session covers. +- **Explore**: search the Hub's public directory of KEGs, flights, and people. +- **Manage**: change the memory system itself: KEGs, settings, schemas, + indexes, locks, flights, and agents. Writing a note never needs these. + +One rule decides between listing and searching. Your own KEGs, flights, +namespaces, schemas, and tags are short, complete lists: read them with +`keg_list`, `flight_list`, `namespace_list`, `schema_list`, and `tag_list`. +Searching is only for note content (`node_search`) and for the public +directory (`explore_kegs`, `explore_flights`, `explore_people`). Explore never +finds notes and never grants access. + Every authority-bearing tool below accepts an optional top-level `flight`. When the connection starts without a flight, omission uses normal identity-authorized full access and an explicit value selects any listed real flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, -`flight_search`, `keg_search`, `namespace_list`, `namespace_search`, +`explore_kegs`, `explore_flights`, `explore_people`, `namespace_list`, `invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. @@ -75,27 +92,35 @@ Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control working, not a bug. To widen what a call can reach, pass a `flight` that covers it. -## Orientation and management +## Session | Tool | Purpose | | --- | --- | -| `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__orient` | Read-only view of no-flight identity authority or the pinned real root, an optional exact real-flight selection, revision, available KEGs, the tool categories, 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`. 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. | +| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | +| `mcp__tapper__namespace_list` | The namespaces you belong to, with your role. | +| `mcp__tapper__invitation_list` | Pending invitations addressed to your user: org memberships and keg grants that take effect only if accepted. Read-only: accepting or declining is your user's decision (Hub account page or `tap invitation`), so tell them rather than acting. | +| `mcp__tapper__guide` | Canonical guidance by topic: `operating`, `authoring` or `linking`, `snapshots`, `tools`, and `troubleshooting`. | + +## Memory: your KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_list` | Every KEG this session can work in, at its real role with no flight, or the effective projection of a selected real flight, with title and description. The list is complete: read it rather than searching. Listings label KEGv2 KEGs. | +| `mcp__tapper__flight_list`, `mcp__tapper__flight_read` | Your readable flights; flight_read returns a permission-filtered declared manifest, including explicit empty collections/instructions and hash. Neither tool activates or selects authority. | -`keg_list` returns `@namespace/kegrole@namespace/+flight` text -(the final field is empty for no-flight authority) and -structured -`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","flights":["@namespace/+flight"]}]}` +`keg_list` returns `@namespace/kegrole@namespace/+flighttitledescription` +text (the flight field is empty for no-flight authority, and empty trailing +fields are dropped) and structured +`{"kegs":[{"ref":"@namespace/keg","role":"viewer|editor|admin","title":"…","description":"…","flights":["@namespace/+flight"]}]}` rows. Omission is the aggregate selector; supplying `flight` requests an exact projection. The removed `all` property is rejected by schema validation. With no flight, aggregate results contain every identity-accessible KEG. With a real pinned root, they are restricted to that root and its currently accessible transitive descendants. -## Search and discovery +## Memory: finding notes | Tool | Purpose | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -108,7 +133,6 @@ accessible transitive descendants. | `mcp__tapper__node_backlinks` | Inbound links to a node. | | `mcp__tapper__index_list`, `mcp__tapper__index_read` | Read generated index files (tag index, changelog, and others). | | `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. | 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 @@ -141,19 +165,22 @@ Examples: Prefer a targeted query over reading many nodes and filtering in your own code; the index does the work in O(matches) rather than O(total). -## Maintenance +## Memory: writing notes | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__node_create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. | -| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. | -| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_edit` | Call `node_read`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`. Each part has its own hash: supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`). Editing one part never invalidates the other's hash, and results return the new `content_hash` and `meta_hash`. For a small change to a long note, send `patch` (a unified diff of the changed lines with a little context) instead of `content`; it needs `expected_content_hash` too, and a patch that no longer fits is refused with the lines it expected. A patch keeps the body's line endings, and `\ No newline at end of file` may only end a hunk that reaches the end of the body. | +| `mcp__tapper__node_move` | Call `node_read`, then relocate a node using the combined `hash` from `node_read` as `expected_hash`. A trashed node's id stays reserved until it is restored or purged. | | `mcp__tapper__node_delete` | Call `node_read`, then atomically remove 1–100 `nodes[]`, each carrying the combined `hash` from `node_read` as `expected_hash`. | +| `mcp__tapper__node_merge` | Deduplicate: `node_read` both, then write the merged body into the target (`content` or `patch`, optional `meta`). Links to the source are repointed at the target and the source goes to the trash as merged. Guard the source with its combined `hash`, the target with `content_hash` (and `meta_hash` with meta). | +| `mcp__tapper__keg_replace` | Manage. Find and replace across a keg in one atomic write (literal or `regex`, narrowed by `query`, optionally in `include_meta`). Run it with `dry_run` first to see every node that would change. A node the write would refuse (schemas, metadata that must parse, a body opening with frontmatter) is reported with its error, without its bodies, and left alone while the rest are written; `dry_run` reports the same errors. At most 500 matching nodes per call, refused ones included. | +| `mcp__tapper__trash_list`, `mcp__tapper__trash_restore` | Where the keg keeps a trash, list removed nodes and why they went, and restore 1–100 of them at their ids with their history and assets. An entry an archive restore moved to make way shows `displaced_from` (the id it was trashed at) and restores at its new id. | | `mcp__tapper__file_delete`, `mcp__tapper__image_delete` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__snapshot_create` | Capture a revision before a destructive or large edit. | | `mcp__tapper__snapshot_list`, `mcp__tapper__snapshot_read` | Inspect read-only prior revisions. | | `mcp__tapper__snapshot_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | +| `mcp__tapper__keg_settings_edit` | Manage. Call `keg_settings_read` with `minimal=false`, then replace the complete YAML `data` using its `hash` as `expected_hash`. Requires identity admin and effective flight admin authority when a flight is selected. | Schema edits and deletes similarly require the hash from `schema_read`. Every conflict performs no operation: merge the change into returned current content @@ -207,7 +234,29 @@ against it, so it needs no `schema`. {"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_READ"}]} ``` -## Agents +## Explore: the public directory + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__explore_kegs` | Public KEGs on the Hub, matched literally by reference, title, or description. | +| `mcp__tapper__explore_flights` | Public flights on the Hub. Metadata only, never instructions. | +| `mcp__tapper__explore_people` | People and org namespaces on the Hub, matched by name or display name. Role is set where you already belong. | + +Each takes a required, non-empty `query` and returns `refnamedescriptionrole` +text and structured `{"kind":"kegs","results":[{"ref","name","description","role"}],"truncated":false}`. +Results are capped; refine a truncated query, there is no cursor. A result +grants nothing: a public KEG outside this session's flight still needs a +flight that covers it before any Memory tool can read it. Explore asks the +connection-pinned Hub, never other configured Hubs. + +## Manage: KEGs and flights + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__keg_create` | Create a KEG; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. Takes an optional immutable `kegv`: `2025-07` (KEGv1, the default) or `2026-09` (KEGv2 preview, where the Hub enables it). | +| `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. | + +## Manage: agents Hub agents are a namespace-owned model, description, markdown instructions, and tool allowlist. A flight names the agents that run on it. Members of a @@ -219,31 +268,18 @@ namespace read its agents; owners and admins change them. Agent refs are | `mcp__tapper__agent_list`, `mcp__tapper__agent_read` | Identity-readable agents in one namespace or every namespace you belong to; `agent_read` adds instructions and `effective_tools`. Neither accepts `flight`. | | `mcp__tapper__agent_create`, `mcp__tapper__agent_edit`, `mcp__tapper__agent_delete` | Manage agents in a namespace you own or administer. A selected real flight must also grant `manage_flights`. `agent_edit` keeps omitted fields; an edit applies to open sessions on their next call. | -An agent's `tools` hold tool group ids (`keg:read`, `keg:write`, `keg:admin`, -`flight:read`, `flight:admin`, `agent:read`, `agent:admin`, `discover`) and -single tool names; empty means every tool. `orient`, `guide`, `session_info`, -and `session_refresh` are always available. - -## On-demand discovery and guidance - -- `mcp__tapper__namespace_list`: the namespaces you belong to, with your role. -- `mcp__tapper__namespace_search`: people and org namespaces visible on the - Hub, not only your own, matched by name or display name; empty query browses. - Discovery only: a match grants nothing. Follow up with `keg_search` and - `flight_search`. -- `mcp__tapper__invitation_list`: pending invitations addressed to your user, - org memberships and keg grants that take effect only if accepted. Read-only: - accepting or declining is your user's decision (Hub account page or - `tap invitation`), so tell them rather than acting. - -- `mcp__tapper__flight_search`: literal reference/title/description search over - readable flights; at most 50 deterministic metadata results, with a truncation - notice. No instructions or additional access are returned. -- `mcp__tapper__guide`: read canonical guidance by topic: `operating`, `authoring` - or `linking`, `snapshots`, `tools`, and `troubleshooting`. -- `mcp__tapper__orient`: full active instructions, effective readable KEGs, - and immediate readable child flights. Orient with a child reference for the - next level. No-flight orientation points to search without listing resources. +An agent's `tools` hold tool group ids and single tool names; empty means +every tool. Groups follow the categories: Memory is `keg:read`, `keg:write`, +and `flight:read`; Explore is `explore`; Manage is `keg:admin`, +`schema:admin`, `keg:maintain`, `flight:admin`, `agent:read`, and +`agent:admin`. The Session tools (`orient`, `guide`, `session_info`, +`session_refresh`, `namespace_list`, and `invitation_list`) are always +available. + +`orient` gives the full active instructions, effective readable KEGs, and +immediate readable child flights. Orient with a child reference for the next +level. No-flight orientation points to `keg_list` and `flight_list` without +listing resources. ## Response and pagination contracts @@ -267,31 +303,36 @@ 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. +The explore tools return truncated and have no cursor; refine the query. Supported flight capabilities are manage_flights, manage_kegs, and delete_kegs; full_access is rejected. Declared cover is not effective authority: inspect orient for the permission-checked relationship expansion. Creating or inspecting a flight does not activate it or change the connection's pinned root. -## Other registered tools +## Memory: schemas, diagnostics, locks, and attachments | Tool | Purpose | | --- | --- | -| `mcp__tapper__session_info` | Credential-free identity and default namespace. Namespace names do not assert administrative membership. | -| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. | -| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. | +| `mcp__tapper__schema_list`, `mcp__tapper__schema_read` | Schema names and YAML data plus hash. Call `schema_list` before writing in a KEG and pick a type it lists. | | `mcp__tapper__schema_validate` | Schema validation findings. | -| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | -| `mcp__tapper__keg_info`, `mcp__tapper__node_stats`, `mcp__tapper__keg_check` | KEG diagnostics, node statistics, health findings. | -| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release`, `mcp__tapper__lock_force_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | +| `mcp__tapper__keg_info`, `mcp__tapper__node_stats` | KEG diagnostics and node statistics. | +| `mcp__tapper__url` | The Hub web link for a KEG, note, flight, or namespace (`ref` as `42`, `@ns/keg/42`, `keg:~alias/42`, `+slug`, `@ns`), returned as `url`, `kind`, and canonical `ref`. Use it to hand the reader a clickable link; it never reads the note. | +| `mcp__tapper__lock_acquire`, `mcp__tapper__lock_status`, `mcp__tapper__lock_release` | Advisory locks; acquisition returns a private token for release. node_edit does not accept a lock token. | | `mcp__tapper__file_list`, `mcp__tapper__image_list` | Stored attachment names. | | `mcp__tapper__file_upload`, `mcp__tapper__image_upload` | Inline base64/data URI/embedded resource uploads; local stdio also accepts source_path. Link the returned stored filename. | | `mcp__tapper__image_download` | Image content block and metadata; local stdio optionally accepts dest_path. | | `mcp__tapper__file_download` | Local stdio only: writes an explicit destination path. Absent on hosted MCP. | +## Manage: schemas and repair + +| Tool | Purpose | +| --- | --- | +| `mcp__tapper__schema_create`, `mcp__tapper__schema_edit`, `mcp__tapper__schema_delete` | Schema lifecycle; requires admin, with current hashes for edit/delete. Writing a note never needs these. | +| `mcp__tapper__index_rebuild` | Index rebuild; requires editor. | +| `mcp__tapper__keg_check` | KEG health findings. | +| `mcp__tapper__lock_force_release` | Break a lock another session holds. | + Configuration, namespace administration, license, archive, and video tools are not registered here. Features present elsewhere in Tapper/Hub are not thereby callable over MCP. Use tools/list for this connection's available inventory. @@ -321,12 +362,19 @@ supplied they must match exactly, or the call is rejected before mutation. are cheap, deduplicated by content hash, and stored inside the node they belong to. -**Important: snapshots are stored inside the node. Removing a node -removes its snapshot history with it. A snapshot does not protect -against `mcp__tapper__node_delete`.** Before a removal, copy the content -somewhere that survives the deletion — read it with `mcp__tapper__node_read` -and keep the result in your working context, or write it to another -node first. If you are not certain the removal is correct, defer it. +**Important: snapshots are stored inside the node. A snapshot does not +protect against `mcp__tapper__node_delete`.** On a hub keg, a removed node +goes to the keg's trash with its snapshots, files, and images: +`mcp__tapper__trash_list` shows it and `mcp__tapper__trash_restore` puts it +back, until the keg's retention purges it. Until then its id stays reserved: +no node can be moved onto it. An archive restore that needs the id moves the +trashed node to a new id first (`displaced_from` in `mcp__tapper__trash_list` +names the old one), and restoring it puts it back at the new id. Elsewhere, removing a node removes +its snapshot history with it. Either way, before a removal you are not sure +of, copy the content somewhere that survives the deletion — read it with +`mcp__tapper__node_read` and keep the result in your working context, or +write it to another node first. If you are not certain the removal is +correct, defer it. Snapshots do protect in-place edits. Take one before any of: @@ -356,7 +404,8 @@ Recover with: If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `node_delete`, a snapshot is not a -recovery path — preserve the content some other way first. +recovery path — the trash is, where the keg has one; otherwise preserve the +content some other way first. Tapper supports these link forms in node bodies: @@ -366,11 +415,19 @@ Tapper supports these link forms in node bodies: - **Cross-keg (same namespace):** `[title](keg:ALIAS/NODEID)` — names a KEG in the source namespace. - **Settings alias:** `[title](keg:~ALIAS/NODEID)` — explicitly resolves an - alias declared in the source KEG settings. The tilde distinguishes a settings - alias from a KEG name. + alias declared in the source KEG settings `aliases`. The tilde distinguishes + a settings alias from a KEG name. An alias keeps links working when the KEG + it names moves: only the alias's target changes. - **Cross-keg (fully qualified):** `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg - explicitly and is parsed into a cross-keg edge. + explicitly and is parsed into a cross-keg edge. `NODEID` may be `0`, the + KEG's root node, to link the KEG as a whole. The `@` is required: + `keg:NAMESPACE/ALIAS/NODEID` is not a link, and `keg_check` reports it. + +On a Hub, a flight that covers a KEG also covers the KEGs its notes link to, +as far as the cover entry's depth reaches, so a cross-keg link can widen what +an agent under that flight may read. A flight blocks a KEG by listing it with +role `none`. Indexed links appear in backlinks when readable. Prefer intra-keg links when the target is in the same keg. A bare `keg:` reference in node prose is plain text: it does not diff --git a/pkg/apicontract/contract.go b/pkg/apicontract/contract.go index ad244647..b1057212 100644 --- a/pkg/apicontract/contract.go +++ b/pkg/apicontract/contract.go @@ -15,7 +15,7 @@ import ( "time" ) -const Revision = "2026-09-28" +const Revision = "2026-10-12" const VersionHeader = "Tapper-API-Version" const ClientHeader = "Tapper-Client-Version" const Required = "API_VERSION_REQUIRED" diff --git a/pkg/apicontract/contract_test.go b/pkg/apicontract/contract_test.go index d947b2a2..7e7412f2 100644 --- a/pkg/apicontract/contract_test.go +++ b/pkg/apicontract/contract_test.go @@ -85,8 +85,8 @@ func TestClientDiscoveryMatrix(t *testing.T) { name, body, code string status int }{ - {"matching", `{"server_version":"0.24.0","api_versions":["2026-09-28"]}`, "", 200}, - {"newer compatible", `{"server_version":"99.0.0","api_versions":["2026-09-28","2099-01-01"]}`, "", 200}, + {"matching", `{"server_version":"0.24.0","api_versions":["2026-10-12"]}`, "", 200}, + {"newer compatible", `{"server_version":"99.0.0","api_versions":["2026-10-12","2099-01-01"]}`, "", 200}, {"newer incompatible", `{"server_version":"99.0.0","api_versions":["2099-01-01"]}`, Unsupported, 200}, {"legacy", "", DiscoveryUnavailable, 404}, {"redirect", "", DiscoveryUnavailable, 302}, {"malformed", "oops", DiscoveryInvalid, 200}, {"missing versions", `{"server_version":"v1"}`, DiscoveryInvalid, 200}, diff --git a/pkg/cli/cmd_context_info_test.go b/pkg/cli/cmd_context_info_test.go new file mode 100644 index 00000000..93531f94 --- /dev/null +++ b/pkg/cli/cmd_context_info_test.go @@ -0,0 +1,74 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestInfoCLI_ShowsHubKegFlightAndFlightKegs(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/whoami": + _ = json.NewEncoder(w).Encode(tapper.WhoAmI{UserID: 1, Username: "jared", DefaultNamespace: "foldwise"}) + case "GET /api/v1/@foldwise/+work": + _ = json.NewEncoder(w).Encode(tapper.HubFlight{ + Namespace: "foldwise", Slug: "work", Title: "Work", + Cover: []tapper.HubFlightCover{{Namespace: "foldwise", Keg: "dev", Role: "editor"}}, + }) + case "GET /api/v1/kegs": + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{ + {Namespace: "foldwise", Alias: "dev", Title: "Development", Role: "admin"}, + {Namespace: "foldwise", Alias: "private", Title: "Outside the flight", Role: "admin"}, + }) + default: + http.NotFound(w, r) + } + })) + defer hub.Close() + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@foldwise/dev\"\nflight: \"@foldwise/+work\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + + res := NewProcess(t, false, "info", "--json").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + var info tapper.ContextInfoResult + require.NoError(t, json.Unmarshal(res.Stdout, &info), string(res.Stdout)) + + require.Equal(t, "cloud", info.Hub.Name) + require.Equal(t, hub.URL, info.Hub.URL) + require.Equal(t, "jared", info.Hub.User) + require.Equal(t, "@foldwise/dev", info.Keg.Ref) + require.Equal(t, "user", info.Keg.Scope) + require.Equal(t, "@foldwise/+work", info.Flight.Ref) + require.Equal(t, "Work", info.Flight.Title) + require.Equal(t, []tapper.ContextInfoFlightKeg{{Ref: "@foldwise/dev", Role: "editor", Title: "Development"}}, info.Kegs, + "only the kegs the flight covers, at the role the flight caps them to") + require.Empty(t, info.Warnings) +} + +func TestInfoCLI_UnreachableHubStillPrintsLocalDefaults(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusServiceUnavailable) + })) + defer hub.Close() + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@foldwise/dev\"\nflight: \"@foldwise/+work\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + + res := NewProcess(t, false, "info").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, "an unreachable hub is a warning, not a failure") + out := string(res.Stdout) + require.Contains(t, out, "name: cloud") + require.Contains(t, out, "ref: '@foldwise/dev'") + require.Contains(t, out, "ref: '@foldwise/+work'") + require.Contains(t, out, "warnings:") +} diff --git a/pkg/cli/cmd_explore.go b/pkg/cli/cmd_explore.go new file mode 100644 index 00000000..92e2601b --- /dev/null +++ b/pkg/cli/cmd_explore.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/spf13/cobra" +) + +// NewExploreCmd returns the `explore` cobra command: the hub's public +// directory. Everything the caller already holds is listed by `keg list`, +// `flight list`, and `namespace list` instead. +// +// Usage examples: +// +// tap explore kegs golang +// tap explore flights onboarding +// tap explore people acme +func NewExploreCmd(deps *Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "explore", + Short: "search the hub's public directory", + Long: `Search the public directory of the active Hub: public KEGs, public +flights, and the people and org namespaces on it. Discovery only: a match +grants nothing. Your own KEGs, flights, and namespaces come from the list +commands.`, + } + cmd.AddCommand( + newExploreKindCmd(deps, "kegs", "search the hub's public KEGs", (*tapper.Tap).ExploreKegs), + newExploreKindCmd(deps, "flights", "search the hub's public flights", (*tapper.Tap).ExploreFlights), + newExploreKindCmd(deps, "people", "search the people and org namespaces on the hub", (*tapper.Tap).ExplorePeople), + ) + return cmd +} + +func newExploreKindCmd(deps *Deps, use, short string, run func(*tapper.Tap, context.Context, tapper.ExploreOptions) (tapper.ExploreResult, error)) *cobra.Command { + return &cobra.Command{ + Use: use + " QUERY", + Short: short, + Long: short + `, printing REF, NAME, DESCRIPTION, ROLE. +ROLE is set where you already belong. The query is matched literally +against references, names, and descriptions.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + res, err := run(deps.Tap, cmd.Context(), tapper.ExploreOptions{Query: args[0], Hub: globalKegTarget(deps).Hub}) + if err != nil { + return err + } + for _, row := range res.Results { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\t%s\n", row.Ref, row.Name, row.Description, row.Role) + } + if res.Truncated { + fmt.Fprintln(cmd.ErrOrStderr(), "warning: results truncated; refine the query") + } + return nil + }, + } +} diff --git a/pkg/cli/cmd_explore_test.go b/pkg/cli/cmd_explore_test.go new file mode 100644 index 00000000..0f7a2477 --- /dev/null +++ b/pkg/cli/cmd_explore_test.go @@ -0,0 +1,45 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestExploreCLI_SearchesEachPublicDirectorySection(t *testing.T) { + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer hub-token", r.Header.Get("Authorization")) + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/explore" { + http.NotFound(w, r) + return + } + var in tapper.ExploreRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&in)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tapper.ExploreResult{ + Kind: in.Kind, + Results: []tapper.ExploreRow{{Ref: "@world/" + string(in.Kind), Name: "Match for " + in.Query, Description: "public"}}, + Truncated: in.Kind == tapper.ExplorePeople, + }) + })) + defer hub.Close() + sb := NewSandbox(t) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(fmt.Sprintf("hub: test\nhubs:\n test: {url: %s, token: hub-token}\n", hub.URL)), 0600)) + + for _, kind := range []string{"kegs", "flights", "people"} { + res := NewProcess(t, false, "explore", kind, "go").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, kind) + require.Equal(t, "@world/"+kind+"\tMatch for go\tpublic\t\n", string(res.Stdout), kind) + if kind == "people" { + require.Contains(t, string(res.Stderr), "results truncated") + } + } + + missing := NewProcess(t, false, "explore", "kegs").Run(sb.Context(), sb.Runtime()) + require.Error(t, missing.Err, "the public directory is searched, never browsed in full") +} diff --git a/pkg/cli/cmd_info.go b/pkg/cli/cmd_info.go index 4817201b..b02ae465 100644 --- a/pkg/cli/cmd_info.go +++ b/pkg/cli/cmd_info.go @@ -1,10 +1,12 @@ package cli import ( + "encoding/json" "fmt" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // NewInfoCmd returns the `info` cobra command. @@ -41,3 +43,46 @@ file/image capabilities. Pass --debug for path and backend diagnostics.`, return cmd } + +// NewContextInfoCmd returns the top-level `info` command: what the CLI is +// pointed at. `tap keg info` describes one KEG instead. +// +// Usage examples: +// +// tap info +// tap info --json +// tap info --flight @me/+work +func NewContextInfoCmd(deps *Deps) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "info", + Short: "show the hub, default keg, flight, and the flight's kegs", + Long: `Show the CLI's current context: the hub and the user signed in to it, the +default keg, the active flight, and the kegs that flight covers with your role +in each. Each default notes the config tier that set it. A hub that cannot be +reached is reported as a warning; the local defaults still print.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var kt tapper.KegTargetOptions + applyKegTargetProfile(deps, &kt) + info, err := deps.Tap.ContextInfo(cmd.Context(), kt) + if err != nil { + return err + } + var out []byte + if asJSON { + out, err = json.MarshalIndent(info, "", " ") + out = append(out, '\n') + } else { + out, err = yaml.Marshal(info) + } + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(out) + return err + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "render as JSON instead of YAML") + return cmd +} diff --git a/pkg/cli/cmd_namespace.go b/pkg/cli/cmd_namespace.go index fed4ca43..908a1c46 100644 --- a/pkg/cli/cmd_namespace.go +++ b/pkg/cli/cmd_namespace.go @@ -32,7 +32,6 @@ manage org-namespace membership and roles, and create org namespaces.`, } cmd.AddCommand( newNamespaceListCmd(deps), - newNamespaceSearchCmd(deps), newNamespaceMembersCmd(deps), newNamespaceAddMemberCmd(deps), newNamespaceSetRoleCmd(deps), @@ -71,34 +70,6 @@ Only the active Hub is queried. Use --hub to select another saved connection.`, return cmd } -func newNamespaceSearchCmd(deps *Deps) *cobra.Command { - return &cobra.Command{ - Use: "search [QUERY]", - Short: "search the namespaces visible on the hub", - Long: `Search the people and org namespaces visible on the active Hub, not only -your own, as NAMESPACE, KIND, DISPLAY NAME, ROLE. ROLE is set where you are a -member. With no query, browse. Discovery only: a match grants nothing.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - query := "" - if len(args) == 1 { - query = args[0] - } - res, err := deps.Tap.NamespaceSearch(cmd.Context(), tapper.NamespaceSearchOptions{Query: query, Hub: globalKegTarget(deps).Hub}) - if err != nil { - return err - } - for _, ns := range res.Namespaces { - fmt.Fprintf(cmd.OutOrStdout(), "@%s\t%s\t%s\t%s\n", ns.Name, ns.Kind, ns.DisplayName, ns.Role) - } - if res.Truncated { - fmt.Fprintln(cmd.ErrOrStderr(), "warning: results truncated; refine the query") - } - return nil - }, - } -} - func newNamespaceMembersCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "members", @@ -127,7 +98,7 @@ func newNamespaceAddMemberCmd(deps *Deps) *cobra.Command { Long: "Invite a member to the namespace selected by --namespace/--hub (default: the resolved namespace).\n\n" + "They join only after accepting the invitation (tap invitation accept). For an existing member the\n" + "role changes immediately. Member management requires the owner role.", - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { kt := globalKegTarget(deps) change, err := deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{ diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index f1730e5d..b79e30c8 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -23,6 +23,14 @@ import ( type shutdownKey struct{} +// localOnlyAnnotation marks a command that works from local configuration, so +// the root skips the startup credential refresh and invocation reporting. +const localOnlyAnnotation = "tapper.io/local-only" + +func runsLocalOnly(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[localOnlyAnnotation] == "true" +} + type Deps struct { Root string Shutdown func() @@ -58,6 +66,11 @@ type Deps struct { // tapper.ValidateToken; tests inject a stub to avoid the network call. AuthValidateTokenFn func(ctx context.Context, rt *toolkit.Runtime, hubURL, token string) (*tapper.WhoAmI, error) + // OpenBrowserFn opens a link in the user's browser for `tap url --open`. + // Nil is lazy-defaulted to tapper.OpenBrowser; tests inject a stub so no + // browser process starts. + OpenBrowserFn func(ctx context.Context, rt *toolkit.Runtime, rawURL string) error + // AuthPrompter renders the interactive hub / method / URL / token prompts // for `tap auth login`. Nil is lazy-defaulted to the huh-backed prompter; // tests inject a scripted fake so the command logic runs without a TTY. @@ -97,6 +110,9 @@ func NewRootCmd(deps *Deps) *cobra.Command { if deps.AuthValidateTokenFn == nil { deps.AuthValidateTokenFn = tapper.ValidateToken } + if deps.OpenBrowserFn == nil { + deps.OpenBrowserFn = tapper.OpenBrowser + } if deps.AuthPrompter == nil { deps.AuthPrompter = huhAuthPrompter{} } @@ -233,7 +249,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { // with fresh tokens. Skipped for shell completion, which must // never block on a network round trip. After the logger setup // so refresh failures land in the configured log destination. - localOnly := cmd.Name() == "bootstrap" || cmd.Name() == "config" || (cmd.Parent() != nil && (cmd.Parent().Name() == "config" || (cmd.Parent().Name() == "hub" && cmd.Name() == "list"))) + localOnly := runsLocalOnly(cmd) || cmd.Name() == "bootstrap" || cmd.Name() == "config" || (cmd.Parent() != nil && (cmd.Parent().Name() == "config" || (cmd.Parent().Name() == "hub" && cmd.Name() == "list"))) if cmd.Flags().Lookup("offline") != nil { offline, _ := cmd.Flags().GetBool("offline") localOnly = localOnly || offline @@ -314,6 +330,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { NewHubCmd(deps), NewImageCmd(deps), NewIndexCmd(deps), + NewContextInfoCmd(deps), NewLockCmd(deps), NewMcpCmd(deps), NewNodeCmd(deps), @@ -334,9 +351,11 @@ func NewRootCmd(deps *Deps) *cobra.Command { configCmd = NewConfigCmd(deps) subcommands = append(subcommands, NewKegCmd(deps), + NewExploreCmd(deps), NewNamespaceCmd(deps), NewInvitationCmd(deps), NewUseCmd(deps), + NewURLCmd(deps), NewBootstrapCmd(deps), configCmd, ) diff --git a/pkg/cli/cmd_root_authrefresh_test.go b/pkg/cli/cmd_root_authrefresh_test.go index 94c35d14..3abdb826 100644 --- a/pkg/cli/cmd_root_authrefresh_test.go +++ b/pkg/cli/cmd_root_authrefresh_test.go @@ -124,3 +124,28 @@ func TestStartup_APITokenNeverRefreshed(t *testing.T) { require.True(t, ok) require.Equal(t, "thub_pastedtoken0", entry.AccessToken, "pasted API token must be untouched") } + +// TestStartup_URLSkipsRefresh: `tap url` builds a link from configuration, +// so it is local-only and never renews credentials, even expired ones. +func TestStartup_URLSkipsRefresh(t *testing.T) { + t.Parallel() + sb := newTestSandbox(t) + srv, calls := countingRefreshHub(t) + + now := sb.Runtime().Clock().Now() + seedAuthStore(t, sb, map[string]tapper.AuthEntry{ + srv.URL: { + AccessToken: "thub_expiredseed0", + TokenType: "Bearer", + ExpiresAt: now.Add(-time.Hour), + RefreshToken: "rt-old", + ClientID: "tapper-cli", + TokenEndpoint: srv.URL, + }, + }) + + res := newAuthProcess(t, nil, "url", "@team/notes/1").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + require.Equal(t, tapper.CanonicalHubURL(srv.URL)+"/@team/notes/1\n", string(res.Stdout)) + require.Equal(t, int64(0), calls.Load(), "tap url must not refresh credentials") +} diff --git a/pkg/cli/cmd_url.go b/pkg/cli/cmd_url.go new file mode 100644 index 00000000..78a93b5f --- /dev/null +++ b/pkg/cli/cmd_url.go @@ -0,0 +1,78 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/spf13/cobra" +) + +// NewURLCmd returns the `url` command: the Hub web link for a KEG, note, +// flight, or namespace. +// +// Usage examples: +// +// tap url +// tap url 42 +// tap url @me/notes/42 --open +// tap url +work --json +func NewURLCmd(deps *Deps) *cobra.Command { + var ( + open bool + asJSON bool + ) + cmd := &cobra.Command{ + Use: "url [REF]", + Short: "print the Hub web link for a keg, note, flight, or namespace", + Long: `Print the Hub web link for REF, or for the current keg when REF is omitted. + +REF is a node id in the current keg (42), a keg (keg:alias, keg:~alias, +@ns/keg), a note in another keg (keg:alias/42, keg:~alias/42, @ns/keg/42), a +flight (+slug, @ns/+slug), a namespace (@ns), or a Hub page URL. The link is +built from configuration; only a keg-local ~alias reads the current keg's +settings. --open also opens the link in the browser.`, + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{localOnlyAnnotation: "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + var opts tapper.WebURLOptions + applyKegTargetProfile(deps, &opts.KegTargetOptions) + if len(args) == 1 { + opts.Ref = args[0] + } + ctx := cmd.Context() + link, err := deps.Tap.WebURL(ctx, opts) + if err != nil { + return err + } + if asJSON { + out, err := json.MarshalIndent(link, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", out) + if err != nil { + return err + } + } else if _, err := fmt.Fprintln(cmd.OutOrStdout(), link.URL); err != nil { + return err + } + if open { + return deps.OpenBrowserFn(ctx, deps.Runtime, link.URL) + } + return nil + }, + } + cmd.Flags().BoolVar(&open, "open", false, "also open the link in the browser") + cmd.Flags().BoolVar(&asJSON, "json", false, "print the url, kind, and canonical ref as JSON") + nodeIDs := nodeIDCompletionFunc(deps, 1) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + out := useArgCompletions(cmd.Context(), deps, toComplete) + ids, _ := nodeIDs(cmd, args, toComplete) + return append(out, ids...), cobra.ShellCompDirectiveNoFileComp + } + return cmd +} diff --git a/pkg/cli/cmd_url_test.go b/pkg/cli/cmd_url_test.go new file mode 100644 index 00000000..4e85d170 --- /dev/null +++ b/pkg/cli/cmd_url_test.go @@ -0,0 +1,126 @@ +package cli_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync/atomic" + "testing" + + testutils "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/cli" + "github.com/stretchr/testify/require" +) + +// newURLSandbox configures a hub whose every request is counted: building a +// link from configuration must not reach it, not even for a credential +// refresh. +func newURLSandbox(t *testing.T) (*testutils.Sandbox, *atomic.Int32, string) { + t.Helper() + var calls atomic.Int32 + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.NotFound(w, r) + })) + t.Cleanup(hub.Close) + sb := NewSandbox(t) + cfg := fmt.Sprintf("hub: cloud\nkeg: \"@team/notes\"\nhubs:\n cloud: {url: %s, token: tok}\n", hub.URL) + require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(cfg), 0o600)) + return sb, &calls, hub.URL +} + +// withBrowser records what `--open` would launch instead of starting a browser. +func withBrowser(ctx context.Context, opened *[]string) context.Context { + return cli.WithTestDepsHook(ctx, func(deps *cli.Deps) { + deps.OpenBrowserFn = func(_ context.Context, _ *toolkit.Runtime, rawURL string) error { + *opened = append(*opened, rawURL) + return nil + } + }) +} + +func TestURLCLI_PrintsTheLinkAlone(t *testing.T) { + t.Parallel() + sb, calls, base := newURLSandbox(t) + var opened []string + ctx := withBrowser(sb.Context(), &opened) + + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"url"}, "/@team/notes"}, + {[]string{"url", "42"}, "/@team/notes/42"}, + {[]string{"url", "@ops/runbooks/7"}, "/@ops/runbooks/7"}, + {[]string{"url", "keg:@ops/library/3"}, "/@ops/library/3"}, + {[]string{"url", "keg:@ops/runbooks"}, "/@ops/runbooks"}, + {[]string{"url", "journal", "--namespace", "ops"}, "/@ops/journal"}, + {[]string{"url", "5", "--keg", "@ops/runbooks"}, "/@ops/runbooks/5"}, + {[]string{"url", "+dev"}, "/@team/+dev"}, + {[]string{"url", "@ops"}, "/@ops"}, + } { + res := NewHubProcess(t, false, tc.args...).Run(ctx, sb.Runtime()) + require.NoError(t, res.Err, tc.args) + require.Equal(t, base+tc.want+"\n", string(res.Stdout), tc.args) + } + require.Empty(t, opened, "without --open nothing is launched") + require.Zero(t, calls.Load(), "a link built from configuration never reaches the hub") +} + +func TestURLCLI_JSON(t *testing.T) { + t.Parallel() + sb, _, base := newURLSandbox(t) + + res := NewHubProcess(t, false, "url", "42", "--json").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + var got map[string]string + require.NoError(t, json.Unmarshal(res.Stdout, &got), string(res.Stdout)) + require.Equal(t, map[string]string{"url": base + "/@team/notes/42", "kind": "node", "ref": "keg:@team/notes/42"}, got) +} + +func TestURLCLI_OpenLaunchesTheBrowser(t *testing.T) { + t.Parallel() + sb, calls, base := newURLSandbox(t) + var opened []string + + res := NewHubProcess(t, false, "url", "@team/notes/9", "--open").Run(withBrowser(sb.Context(), &opened), sb.Runtime()) + require.NoError(t, res.Err) + require.Equal(t, base+"/@team/notes/9\n", string(res.Stdout)) + require.Equal(t, []string{base + "/@team/notes/9"}, opened) + require.Zero(t, calls.Load()) +} + +func TestURLCLI_RejectsAnUnrecognizedRef(t *testing.T) { + t.Parallel() + sb, _, _ := newURLSandbox(t) + var opened []string + + res := NewHubProcess(t, false, "url", "Not A Keg!", "--open").Run(withBrowser(sb.Context(), &opened), sb.Runtime()) + require.ErrorContains(t, res.Err, "unrecognized reference") + require.Empty(t, res.Stdout) + require.Empty(t, opened) +} + +func TestURLCompletion_SuggestsKegsFlightsAndNodeIDs(t *testing.T) { + t.Parallel() + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) + + comp := NewCompletionProcess(t, false, 0, "url", "").Run(sb.Context(), sb.Runtime()) + require.NoError(t, comp.Err) + suggestions := parseCompletionSuggestions(string(comp.Stdout)) + require.Contains(t, suggestions, "@team/personal") + require.Contains(t, suggestions, "@team/+backend") + + local := NewSandbox(t, testutils.WithFixture("joe", "~")) + comp = NewCompletionProcess(t, false, 0, "url", "--keg", "personal", "").Run(local.Context(), local.Runtime()) + require.NoError(t, comp.Err) + suggestions = parseCompletionSuggestions(string(comp.Stdout)) + require.Subset(t, suggestions, []string{"0", "1", "2", "3"}) + + comp = NewCompletionProcess(t, false, 0, "url", "--keg", "personal", "1", "").Run(local.Context(), local.Runtime()) + require.NoError(t, comp.Err) + require.Empty(t, parseCompletionSuggestions(string(comp.Stdout))) +} diff --git a/pkg/cli/cmd_use.go b/pkg/cli/cmd_use.go index 544a7e9c..5f62889c 100644 --- a/pkg/cli/cmd_use.go +++ b/pkg/cli/cmd_use.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "strings" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" @@ -52,7 +51,7 @@ and flight and the source that set each.`, return err } - if len(args) == 1 && opts.Flight == "" && isFlightUseArg(args[0]) { + if len(args) == 1 && opts.Flight == "" && tapper.IsFlightRef(args[0]) { opts.Flight = args[0] } else if len(args) == 1 { opts.Keg = args[0] @@ -92,15 +91,3 @@ func useArgCompletions(ctx context.Context, deps *Deps, toComplete string) []str add(flightFlagCompletions(ctx, deps, toComplete)) return out } - -func isFlightUseArg(raw string) bool { - raw = strings.TrimSpace(raw) - if strings.HasPrefix(raw, "+") { - return true - } - if rest, ok := strings.CutPrefix(raw, "@"); ok { - _, slug, found := strings.Cut(rest, "/") - return found && strings.HasPrefix(slug, "+") - } - return false -} diff --git a/pkg/keg/archive.go b/pkg/keg/archive.go index ed77c433..cee83042 100644 --- a/pkg/keg/archive.go +++ b/pkg/keg/archive.go @@ -416,6 +416,17 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode if err != nil { return nil, err } + if opts.restoreTo != nil { + if len(ordered) != 1 { + return nil, fmt.Errorf("a trash bundle holds one node, not %d: %w", len(ordered), ErrInvalid) + } + mapping[ordered[0]] = *opts.restoreTo + } + if opts.restoreFromTrash { + if err := k.mapPurgedLinks(ctx, entries, ordered, mapping); err != nil { + return nil, err + } + } if opts.AssignNewIDs && len(ordered) > 0 { reserved := make([]NodeId, 0, len(ordered)) defer func() { k.cleanupUnusedImportReservations(ctx, reserved) }() @@ -490,6 +501,46 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode } } + // An archive always lands on its own ids. A trashed node holding one + // of them moves to a fresh id first, so the trashed copy stays + // restorable rather than being lost or blocking the import. + if !opts.AssignNewIDs && !opts.restoreFromTrash { + landing := make(map[NodeId]bool, len(ordered)) + highest := NodeId{} + for _, sourceID := range ordered { + id := mapping[sourceID] + landing[id] = true + if id.Code == "" && id.ID > highest.ID { + highest = id + } + } + // Raise the counter past every id the archive lands on before + // displacing anything, so no displaced entry is handed one of them. + if counter, ok := k.Repo.(RepositoryNodeCounter); ok && len(ordered) > 0 { + if err := counter.RaiseNodeCounter(ctx, highest); err != nil { + return nil, fmt.Errorf("unable to reserve the archive's node ids: %w", err) + } + } + for _, sourceID := range ordered { + if _, _, err := k.displaceTrashed(ctx, mapping[sourceID], landing); err != nil { + return nil, err + } + } + } + // A trash restore never replaces a live node: refuse before anything + // is written. + if opts.restoreFromTrash { + for _, sourceID := range ordered { + exists, err := k.nodeExistsWithContent(ctx, mapping[sourceID]) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("node %s exists, so a trashed copy cannot be restored over it: %w", mapping[sourceID].Path(), ErrDestinationExists) + } + } + } + // Preserve assets of nodes about to be replaced, then delete them so the // archive payload lands on clean state. preservedAssets := make(map[string]importedNodeAssets, len(ordered)) @@ -580,12 +631,16 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode return nil, fmt.Errorf("unable to restore keg settings after import: %w", err) } } - if err := k.rebuildDexFromRepo(ctx); err != nil { - return nil, err - } - if !manifest.WithSettings { - if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { - return nil, fmt.Errorf("unable to update keg settings after import: %w", err) + // A trash restore updates the dex itself, once for its whole batch, + // rather than rebuilding it from every node for each restored one. + if !opts.restoreFromTrash { + if err := k.rebuildDexFromRepo(ctx); err != nil { + return nil, err + } + if !manifest.WithSettings { + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + return nil, fmt.Errorf("unable to update keg settings after import: %w", err) + } } } diff --git a/pkg/keg/archive_test.go b/pkg/keg/archive_test.go index f5ab2780..d53cc9a7 100644 --- a/pkg/keg/archive_test.go +++ b/pkg/keg/archive_test.go @@ -250,7 +250,7 @@ func TestArchiveImportRestoresKegSettingsForFullBackup(t *testing.T) { cfg.Description = "Restored summary" cfg.Instructions = "Restore carefully." cfg.Timezone = "America/Chicago" - cfg.Links = []keg.LinkEntry{{Alias: "docs", URL: "https://example.com/docs"}} + cfg.Aliases = []keg.AliasEntry{{Alias: "docs", URL: "https://example.com/docs"}} cfg.Indexes = append(cfg.UserIndexEntries(), keg.IndexEntry{File: "restored.md", Summary: "Restored nodes", Query: "restored"}) cfg.Snapshots = &keg.SnapshotSettings{Mode: keg.SnapshotModeOff, IdleAfter: "2h"} cfg.SchemaPolicy = &keg.SchemaPolicy{ @@ -282,7 +282,7 @@ func TestArchiveImportRestoresKegSettingsForFullBackup(t *testing.T) { require.Equal(t, "Restored summary", got.Description) require.Equal(t, "Restore carefully.", got.Instructions) require.Equal(t, "America/Chicago", got.Timezone) - require.Equal(t, []keg.LinkEntry{{Alias: "docs", URL: "https://example.com/docs"}}, got.Links) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "https://example.com/docs"}}, got.Aliases) require.Equal(t, &keg.SnapshotSettings{Mode: keg.SnapshotModeOff, IdleAfter: "2h"}, got.Snapshots) require.Equal(t, &keg.SchemaPolicy{Human: keg.ValidationModeWarn, Agent: keg.ValidationModeBlock, API: keg.ValidationModeBlock}, got.SchemaPolicy) diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index 3f0c31a0..50bdef6d 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -244,13 +244,18 @@ type NodeOpenOptions struct { } type NodeUpdateOptions struct { - ID NodeId `json:"id"` - Schema string `json:"schema,omitempty"` - Content []byte `json:"content"` - HasContent bool `json:"has_content,omitempty"` - Meta []byte `json:"meta,omitempty"` - HasMeta bool `json:"has_meta,omitempty"` - LockToken LockToken `json:"lock_token,omitempty"` + ID NodeId `json:"id"` + Schema string `json:"schema,omitempty"` + Content []byte `json:"content"` + HasContent bool `json:"has_content,omitempty"` + // ContentPatch is a unified diff applied to the node's current content + // (see ApplyUnifiedPatch), an alternative to Content. It is checked + // like a content write: it needs ExpectedContentHash or ExpectedHash. + ContentPatch string `json:"content_patch,omitempty"` + HasContentPatch bool `json:"has_content_patch,omitempty"` + Meta []byte `json:"meta,omitempty"` + HasMeta bool `json:"has_meta,omitempty"` + LockToken LockToken `json:"lock_token,omitempty"` // ExpectedHash is the combined content+metadata token (NodeView.Hash). // It is checked only when neither part token below is set. ExpectedHash string `json:"expected_hash,omitempty"` @@ -734,6 +739,9 @@ func (k *LocalKeg) doctor(ctx context.Context) ([]DoctorIssue, error) { issues = append(issues, DoctorIssue{Level: "error", Kind: "broken-link", NodeID: path, Message: fmt.Sprintf("broken link to node %s", link.Path())}) } } + for _, problem := range KegLinkProblems(content, cfg.Aliases) { + issues = append(issues, DoctorIssue{Level: "error", Kind: "malformed-keg-link", NodeID: path, Message: problem}) + } } rawMeta, metaErr := k.GetMetaRaw(ctx, id) if metaErr == nil { @@ -819,7 +827,7 @@ func (k *LocalKeg) removeNodes(ctx context.Context, opts RemoveNodesOptions) (Re } } for _, item := range items { - rewritten, err := k.removeUnchecked(ctx, item.ID) + rewritten, err := k.removeUnchecked(ctx, item.ID, manualRemoval) if err != nil { result.Failure = newBatchFailure(item.ID, err) return result, nil @@ -919,7 +927,7 @@ func (k *LocalKeg) openNode(ctx context.Context, opts NodeOpenOptions) (*NodeVie } func (k *LocalKeg) UpdateNode(ctx context.Context, opts NodeUpdateOptions) (*NodeUpdateResult, error) { - opts.HasContent = true + opts.HasContent = !opts.HasContentPatch results, err := k.UpdateNodes(ctx, []NodeUpdateOptions{opts}) if len(results) == 0 { return nil, err diff --git a/pkg/keg/keg_batch.go b/pkg/keg/keg_batch.go index 355f71cf..c1da9c29 100644 --- a/pkg/keg/keg_batch.go +++ b/pkg/keg/keg_batch.go @@ -147,11 +147,49 @@ func expandNodePlaceholders(body []byte, ids map[string]NodeId) ([]byte, error) } type preparedNodeUpdate struct { - opts NodeUpdateOptions - data *NodeData - content []byte - validation *SchemaValidationResult - backup *aggregateBackup + opts NodeUpdateOptions + // existing is the node as it was read before the update. + existing *NodeView + data *NodeData + content []byte + // writeContent says the update carries a body (full content or a + // patch), so content is written. + writeContent bool + validation *SchemaValidationResult + backup *aggregateBackup +} + +// PlanContentUpdate works out the body an update would leave a node with, +// given the node's current body. It reports whether the update writes a body +// at all; without content or a patch it returns existing unchanged. +// +// Content and a content patch are alternatives. A patch must apply to +// existing exactly (see ApplyUnifiedPatch). Either way the result may open +// with a frontmatter block only when existing already does: a stored body +// written before the no-frontmatter rule stays editable, but no update can +// introduce one. +func PlanContentUpdate(existing []byte, opts NodeUpdateOptions) ([]byte, bool, error) { + var result []byte + switch { + case opts.HasContent && opts.HasContentPatch: + return nil, false, fmt.Errorf("content and a content patch are alternatives; send one: %w", ErrInvalid) + case opts.HasContentPatch: + patched, err := ApplyUnifiedPatch(existing, opts.ContentPatch) + if err != nil { + return nil, false, err + } + result = patched + case opts.HasContent: + result = opts.Content + default: + return existing, false, nil + } + if RejectFrontmatter(existing) == nil { + if err := RejectFrontmatter(result); err != nil { + return nil, false, err + } + } + return result, true, nil } func (k *LocalKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions) ([]NodeUpdateResult, error) { @@ -168,81 +206,26 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) if !opts.ID.Valid() { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("node id is required: %w", ErrInvalid)} } - if !opts.HasContent && !opts.HasMeta { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content or metadata replacement is required: %w", ErrInvalid)} + if !opts.HasContent && !opts.HasContentPatch && !opts.HasMeta { + return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content, a content patch, or metadata replacement is required: %w", ErrInvalid)} + } + if opts.HasContent && opts.HasContentPatch { + return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("content and a content patch are alternatives; send one: %w", ErrInvalid)} } if first, ok := seen[opts.ID]; ok { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("duplicate node id (first at index %d): %w", first, ErrInvalid)} } seen[opts.ID] = i - existing, err := k.ReadNode(ctx, opts.ID) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - backup, err := k.captureAggregateBackup(ctx, existing) + item, err := k.prepareNodeUpdate(ctx, opts) if err != nil { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } - if err := k.validateAggregateLock(ctx, opts.ID, opts.LockToken); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - if err := checkNodeUpdatePrecondition(ctx, k.Runtime, opts, existing); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - contentBytes := existing.Content - if opts.HasContent { - // Only caller-supplied content is held to the no-frontmatter rule. - // A node written before the rule existed may have a stored body - // that opens with `---`; checking contentBytes would make such a - // node permanently unupdatable. - if err := RejectFrontmatter(opts.Content); err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - contentBytes = opts.Content - } - metaBytes := existing.Meta - if opts.HasMeta { - metaBytes = opts.Meta - } - content, err := ParseContent(k.Runtime, contentBytes, MarkdownContentFilename) + backup, err := k.captureAggregateBackup(ctx, item.existing) if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("invalid content: %w", err)} - } - meta, err := ParseMeta(ctx, metaBytes) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("invalid metadata: %s: %w", err, ErrInvalid)} - } - stats, err := cloneNodeStats(ctx, existing.Stats) - if err != nil { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - 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 { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} - } - // Only meta can declare a type: RejectFrontmatter above guarantees - // submitted content carries none. - 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) { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } - if validation != nil && validation.Valid { - validation = nil - } - prepared[i] = preparedNodeUpdate{opts: opts, data: data, content: contentBytes, validation: validation, backup: backup} + item.backup = backup + prepared[i] = item } results := make([]NodeUpdateResult, len(prepared)) createdSnapshots := false @@ -259,7 +242,7 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) return err } } - if item.opts.HasContent { + if item.writeContent { if err := k.Repo.WriteContent(lockCtx, item.opts.ID, item.content); err != nil { return err } @@ -294,6 +277,76 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) return results, nil } +// prepareNodeUpdate checks one update against the node as it stands and +// builds what it would write, without writing anything: the lock, the +// precondition tokens, the planned body (PlanContentUpdate), metadata that +// parses, and the keg's schema rules. Any error means the update would be +// refused. +func (k *LocalKeg) prepareNodeUpdate(ctx context.Context, opts NodeUpdateOptions) (preparedNodeUpdate, error) { + existing, err := k.ReadNode(ctx, opts.ID) + if err != nil { + return preparedNodeUpdate{}, err + } + if err := k.validateAggregateLock(ctx, opts.ID, opts.LockToken); err != nil { + return preparedNodeUpdate{}, err + } + // A patch is a content write: it is held to the content token, and + // checked before it is applied so a stale token reports a conflict + // rather than a patch that no longer fits. + check := opts + if opts.HasContentPatch { + check.HasContent = true + } + if err := checkNodeUpdatePrecondition(ctx, k.Runtime, check, existing); err != nil { + return preparedNodeUpdate{}, err + } + contentBytes, writeContent, err := PlanContentUpdate(existing.Content, opts) + if err != nil { + return preparedNodeUpdate{}, err + } + metaBytes := existing.Meta + if opts.HasMeta { + metaBytes = opts.Meta + } + content, err := ParseContent(k.Runtime, contentBytes, MarkdownContentFilename) + if err != nil { + return preparedNodeUpdate{}, fmt.Errorf("invalid content: %w", err) + } + meta, err := ParseMeta(ctx, metaBytes) + if err != nil { + return preparedNodeUpdate{}, fmt.Errorf("invalid metadata: %s: %w", err, ErrInvalid) + } + stats, err := cloneNodeStats(ctx, existing.Stats) + if err != nil { + return preparedNodeUpdate{}, err + } + 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 { + return preparedNodeUpdate{}, err + } + // Only meta can declare a type: content may not introduce a frontmatter + // block, and a legacy one is not read as a type. + candidate := kept + if opts.HasMeta { + candidate = schemaTypeCandidateFromMeta("metadata", meta) + } + validation, err := k.validateNodeWrite(ctx, schemaWriteUpdate, opts.ID, data, opts.Schema, candidate) + if err != nil && !errors.Is(err, ErrNotSupported) { + return preparedNodeUpdate{}, err + } + if validation != nil && validation.Valid { + validation = nil + } + return preparedNodeUpdate{opts: opts, existing: existing, data: data, content: contentBytes, writeContent: writeContent, validation: validation}, nil +} + func (k *LocalKeg) AppendSnapshots(ctx context.Context, nodes []NodeSnapshotRequest) ([]Snapshot, error) { return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) ([]Snapshot, error) { if err := validateMutationBatchSize(len(nodes)); err != nil { diff --git a/pkg/keg/keg_batch_test.go b/pkg/keg/keg_batch_test.go index 534e61d0..3b4253be 100644 --- a/pkg/keg/keg_batch_test.go +++ b/pkg/keg/keg_batch_test.go @@ -365,3 +365,56 @@ func TestStrictArchiveImportRemainsExempt(t *testing.T) { require.NoError(t, err) require.Len(t, after, len(before)+1) } + +func TestUpdateKeepsALegacyFrontmatterBodyEditable(t *testing.T) { + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + legacy, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# T\n")}) + require.NoError(t, err) + // Store the legacy shape past the write guard, as a node predating the + // no-frontmatter rule already is. + require.NoError(t, k.Repo.WriteContent(ctx, legacy.ID, []byte("---\na: 1\n---\n# T\n"))) + view, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: legacy.ID, Content: []byte("---\na: 1\n---\n# T\n\nMore.\n"), HasContent: true, ExpectedContentHash: view.ContentHash(), + }}) + require.NoError(t, err, "a full-content edit keeping the stored block is allowed") + got, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + require.Equal(t, "---\na: 1\n---\n# T\n\nMore.\n", string(got.Content)) + + normal, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# N\n")}) + require.NoError(t, err) + nview, err := k.ReadNode(ctx, normal.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: normal.ID, Content: []byte("---\na: 1\n---\n# N\n"), HasContent: true, ExpectedContentHash: nview.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid, "content may not introduce a frontmatter block") + unchanged, err := k.ReadNode(ctx, normal.ID) + require.NoError(t, err) + require.Equal(t, "# N\n", string(unchanged.Content)) +} + +func TestPlanContentUpdate(t *testing.T) { + t.Parallel() + got, has, err := keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{}) + require.NoError(t, err) + require.False(t, has) + require.Equal(t, "# A\n", string(got)) + + got, has, err = keg.PlanContentUpdate([]byte("# A\nx\n"), keg.NodeUpdateOptions{ContentPatch: "@@ -2 +2 @@\n-x\n+y\n", HasContentPatch: true}) + require.NoError(t, err) + require.True(t, has) + require.Equal(t, "# A\ny\n", string(got)) + + _, _, err = keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{Content: []byte("# B\n"), HasContent: true, ContentPatch: "@@ -1 +1 @@\n-# A\n+# B\n", HasContentPatch: true}) + require.ErrorIs(t, err, keg.ErrInvalid) + + _, _, err = keg.PlanContentUpdate([]byte("# A\n"), keg.NodeUpdateOptions{ContentPatch: "@@ -1 +1,3 @@\n-# A\n+---\n+a: 1\n+---\n", HasContentPatch: true}) + require.ErrorIs(t, err, keg.ErrInvalid) +} diff --git a/pkg/keg/keg_iface.go b/pkg/keg/keg_iface.go index d67b8390..0dfb05ce 100644 --- a/pkg/keg/keg_iface.go +++ b/pkg/keg/keg_iface.go @@ -110,6 +110,33 @@ type Keg interface { // the ids of nodes whose content was rewritten. Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) + // MergeNodes folds a source node into a target as one write: the target + // takes the merged body, links to the source are pointed at the target, + // and the source is removed (to the trash where the keg keeps one). + MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) + + // ReplaceText finds and replaces text across the keg as one write, or + // with DryRun only reports what would change. + ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) + + // ListTrash returns removed nodes kept for restoring, newest first. + // ErrNotSupported when the keg keeps no trash. + ListTrash(ctx context.Context) ([]TrashEntry, error) + + // TrashInfo returns the trash entry for one id, the cheap check for + // whether that id is trashed. ErrNotExist when the id has no entry, + // ErrNotSupported when the keg keeps no trash. + TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) + + // RestoreTrashed puts 1-100 trashed nodes back at their ids, all or + // nothing. + RestoreTrashed(ctx context.Context, ids []NodeId) error + + // PurgeTrashed deletes 1-100 trashed nodes for good, as one write, and + // points links that still name them at node 0. It reports the ids it + // purged and the nodes whose links it rewrote. + PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) + // Commit promotes a temporary, code-backed node to a permanent numeric id. // It is a no-op for an already-permanent node. Commit(ctx context.Context, id NodeId) error @@ -434,6 +461,13 @@ type ImportNodesOptions struct { // TargetAlias, when set, rewrites keg:TargetAlias/N links in imported // content to relative ../N links. TargetAlias string + // restoreFromTrash lets the import land on ids held by trashed nodes; only + // restoring from the trash does that. + restoreFromTrash bool + // restoreTo lands a single-node archive (a trash bundle) on this id + // rather than its own, its links to itself following. Restoring from + // the trash always sets it to the entry's current id. + restoreTo *NodeId } // ImportedNode maps an archive source id to the node id it landed on. diff --git a/pkg/keg/keg_local_move.go b/pkg/keg/keg_local_move.go index 36bc840a..59703bcd 100644 --- a/pkg/keg/keg_local_move.go +++ b/pkg/keg/keg_local_move.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "regexp" + "slices" + "strings" ) // Move renames a node from src to dst and rewrites in-content links that @@ -57,47 +59,19 @@ func (k *LocalKeg) move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, er if dstExists { return nil, fmt.Errorf("destination node %s already exists: %w", dst.Path(), ErrDestinationExists) } + dstTrashed, err := k.idTrashed(ctx, dst) + if err != nil { + return nil, fmt.Errorf("failed to check the trash for the destination node: %w", err) + } + if dstTrashed { + return nil, fmt.Errorf("node %s is in the trash; restore or purge it before moving a node there: %w", dst.Path(), ErrDestinationExists) + } if err := k.Repo.MoveNode(ctx, src, dst); err != nil { return nil, fmt.Errorf("failed to move node %s to %s: %w", src.Path(), dst.Path(), err) } - ids, err := k.Repo.ListNodes(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list nodes for link rewrite: %w", err) - } - - // Rewrite links in all nodes, collecting changed NodeData without - // writing the dex after each individual change. A single batched dex - // write follows the loop. - var errs []error - var changedNodes []*NodeData - linkRE := compileNodeLinkPattern(src) - for _, id := range ids { - raw, readErr := k.Repo.ReadContent(ctx, id) - if readErr != nil { - if errors.Is(readErr, ErrNotExist) { - continue - } - errs = append(errs, fmt.Errorf("failed to read node content %s: %w", id.Path(), readErr)) - continue - } - - updated, changed := rewriteNodeLinks(raw, linkRE, dst) - if !changed { - continue - } - // Link rewrites are part of the move operation rather than a live node - // edit, so strict explicit-selection does not apply. - nd, err := k.setContentNoDex(WithValidationMode(ctx, ValidationModeOff), id, updated) - if err != nil { - errs = append(errs, fmt.Errorf("failed to rewrite links for node %s: %w", id.Path(), err)) - continue - } - if nd != nil { - changedNodes = append(changedNodes, nd) - } - } + changedNodes, errs := k.rewriteLinksNoDex(ctx, src, dst) // Single batched dex update: load fresh dex, apply all changes, write once. dex, err := k.ensureDexFresh(ctx) @@ -173,10 +147,13 @@ func (k *LocalKeg) remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId if err := checkExpectedHash("node "+id.Path(), opts.ExpectedHash, current.Hash(), nodeRecoveryContent(current)); err != nil { return nil, err } - return k.removeUnchecked(ctx, id) + return k.removeUnchecked(ctx, id, manualRemoval) } -func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, error) { +// removeUnchecked removes id without checking its hash. A repository with a +// trash keeps the node there and leaves links to it alone, so restoring it is +// lossless; otherwise the node is deleted and links to it point at node 0. +func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId, info trashInfo) ([]NodeId, error) { if !id.Valid() { return nil, fmt.Errorf("invalid node id: %w", ErrInvalid) } @@ -197,6 +174,13 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er return nil, fmt.Errorf("node %s not found: %w", id.Path(), ErrNotExist) } + if trash, ok := repoTrash(k.Repo); ok { + if err := k.trashNode(ctx, trash, id, info); err != nil { + return nil, err + } + return nil, k.afterRemove(ctx, id, nil) + } + // Acquire node lock to prevent concurrent writes from resurrecting // the node directory after deletion. Re-check existence under lock. if err := k.withNodeLock(ctx, id, func(lockCtx context.Context) error { @@ -247,6 +231,12 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er } } + return rewritten, k.afterRemove(ctx, id, errs) +} + +// afterRemove drops a removed node from the dex and stamps the keg, adding +// any failure to errs. +func (k *LocalKeg) afterRemove(ctx context.Context, id NodeId, errs []error) error { // Single batched dex update: remove deleted node, write once. dex, err := k.ensureDexFresh(ctx) if err != nil { @@ -271,7 +261,95 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er if err := k.refreshSnapshotGeneratedIndexes(ctx); err != nil { errs = append(errs, fmt.Errorf("failed to refresh snapshot indexes after remove: %w", err)) } + return errors.Join(errs...) +} + +// rewriteLinksNoDex points every ../from link in the keg at to, returning the +// nodes it rewrote for the caller's single dex update. Link rewrites belong to +// the operation that caused them rather than being live edits, so strict +// explicit-selection does not apply. Nodes in skip are neither read nor +// rewritten. +func (k *LocalKeg) rewriteLinksNoDex(ctx context.Context, from, to NodeId, skip ...NodeId) ([]*NodeData, []error) { + return k.rewriteMatchingLinksNoDex(ctx, compileNodeLinkPattern(from), to, skip...) +} +// rewriteMatchingLinksNoDex points every link linkRE matches at to, in one +// scan of the keg. +func (k *LocalKeg) rewriteMatchingLinksNoDex(ctx context.Context, linkRE *regexp.Regexp, to NodeId, skip ...NodeId) ([]*NodeData, []error) { + ids, err := k.Repo.ListNodes(ctx) + if err != nil { + return nil, []error{fmt.Errorf("failed to list nodes for link rewrite: %w", err)} + } + var errs []error + var changedNodes []*NodeData + for _, id := range ids { + if slices.Contains(skip, id) { + continue + } + raw, readErr := k.Repo.ReadContent(ctx, id) + if readErr != nil { + if errors.Is(readErr, ErrNotExist) { + continue + } + errs = append(errs, fmt.Errorf("failed to read node content %s: %w", id.Path(), readErr)) + continue + } + updated, changed := rewriteNodeLinks(raw, linkRE, to) + if !changed { + continue + } + nd, err := k.setContentNoDex(WithValidationMode(ctx, ValidationModeOff), id, updated) + if err != nil { + errs = append(errs, fmt.Errorf("failed to rewrite links for node %s: %w", id.Path(), err)) + continue + } + if nd != nil { + changedNodes = append(changedNodes, nd) + } + } + return changedNodes, errs +} + +// retargetLinks points every ../from link in the keg at to and brings the dex +// up to date in one write. It returns the ids of the rewritten nodes. Nodes +// in skip are left as they are. +func (k *LocalKeg) retargetLinks(ctx context.Context, from, to NodeId, skip ...NodeId) ([]NodeId, error) { + return k.retargetLinksMany(ctx, []NodeId{from}, to, skip...) +} + +// retargetLinksMany is retargetLinks for several ids at once: one scan of +// the keg and one dex write. +func (k *LocalKeg) retargetLinksMany(ctx context.Context, from []NodeId, to NodeId, skip ...NodeId) ([]NodeId, error) { + changedNodes, errs := k.rewriteMatchingLinksNoDex(ctx, compileNodeLinksPattern(from), to, skip...) + rewritten := make([]NodeId, 0, len(changedNodes)) + for _, nd := range changedNodes { + rewritten = append(rewritten, nd.ID) + } + if len(changedNodes) == 0 { + return rewritten, errors.Join(errs...) + } + dex, err := k.ensureDexFresh(ctx) + if err != nil { + return rewritten, errors.Join(append(errs, fmt.Errorf("failed to retrieve dex after link rewrite: %w", err))...) + } + for _, nd := range changedNodes { + if err := dex.Remove(ctx, nd.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove stale dex entry for %s: %w", nd.ID.Path(), err)) + } + if err := dex.Add(ctx, nd); err != nil { + errs = append(errs, fmt.Errorf("failed to add changed node %s to dex: %w", nd.ID.Path(), err)) + } + } + if err := dex.Write(ctx, k.Repo); err != nil { + errs = append(errs, fmt.Errorf("failed to write dex after link rewrite: %w", err)) + } else { + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + } + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after link rewrite: %w", err)) + } return rewritten, errors.Join(errs...) } @@ -279,12 +357,27 @@ func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, er // links "../N" for the given source node ID. Pre-compile once and pass to // rewriteNodeLinks to avoid recompilation per call. func compileNodeLinkPattern(src NodeId) *regexp.Regexp { - oldID := src.Path() + return compileNodeLinksPattern([]NodeId{src}) +} + +// compileNodeLinksPattern matches canonical "../N" links to any of ids. +func compileNodeLinksPattern(ids []NodeId) *regexp.Regexp { + alternatives := make([]string, len(ids)) + for i, id := range ids { + alternatives[i] = regexp.QuoteMeta(id.Path()) + } delimiters := `[[:space:]\)\]\}\>\.,;:!?'\"#]` - pattern := `\.\./\s*` + regexp.QuoteMeta(oldID) + `(` + delimiters + `|$)` + pattern := `\.\./\s*(?:` + strings.Join(alternatives, "|") + `)(` + delimiters + `|$)` return regexp.MustCompile(pattern) } +// RetargetLinks returns content with every canonical "../from" node link +// pointed at to, as moving or merging a node rewrites the keg's links. +func RetargetLinks(content []byte, from, to NodeId) []byte { + updated, _ := rewriteNodeLinks(content, compileNodeLinkPattern(from), to) + return updated +} + func rewriteNodeLinks(raw []byte, re *regexp.Regexp, dst NodeId) ([]byte, bool) { newID := dst.Path() if newID == "" || len(raw) == 0 { diff --git a/pkg/keg/keg_local_settings.go b/pkg/keg/keg_local_settings.go index 2dc2efbb..9c9593da 100644 --- a/pkg/keg/keg_local_settings.go +++ b/pkg/keg/keg_local_settings.go @@ -68,7 +68,7 @@ func (k *LocalKeg) updateSettings(ctx context.Context, f func(*Settings)) error } } f(cfg) - if err := ValidateRelationships(cfg.Links); err != nil { + if err := ValidateAliases(cfg.Aliases); err != nil { return err } if err := k.Repo.WriteSettings(ctx, cfg); err != nil { @@ -105,7 +105,7 @@ func (k *LocalKeg) replaceSettings(ctx context.Context, data []byte) error { if err != nil { return fmt.Errorf("unable to parse settings: %w", err) } - if err := ValidateRelationships(cfg.Links); err != nil { + if err := ValidateAliases(cfg.Aliases); err != nil { return err } if store, ok := k.Repo.(RepositorySettingsDocuments); ok { diff --git a/pkg/keg/keg_remote_aggregate.go b/pkg/keg/keg_remote_aggregate.go index fdffbb4a..2be8a6eb 100644 --- a/pkg/keg/keg_remote_aggregate.go +++ b/pkg/keg/keg_remote_aggregate.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) func (k *RemoteKeg) ListEntries(ctx context.Context, opts ListEntriesOptions) (*ListEntriesResult, error) { @@ -196,7 +197,9 @@ func (k *RemoteKeg) OpenNode(ctx context.Context, opts NodeOpenOptions) (*NodeVi } func (k *RemoteKeg) UpdateNode(ctx context.Context, opts NodeUpdateOptions) (*NodeUpdateResult, error) { - opts.HasContent = true + // A single-node update carries content unless it carries a patch, as + // LocalKeg.UpdateNode treats it. + opts.HasContent = !opts.HasContentPatch results, err := k.UpdateNodes(ctx, []NodeUpdateOptions{opts}) if len(results) == 0 { return nil, err @@ -245,6 +248,7 @@ func (k *RemoteKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions NodeID int `json:"node_id"` Schema string `json:"schema,omitempty"` Content *string `json:"content,omitempty"` + ContentPatch *string `json:"content_patch,omitempty"` Meta *string `json:"meta,omitempty"` LockToken string `json:"lock_token,omitempty"` ExpectedHash string `json:"expected_hash,omitempty"` @@ -270,6 +274,10 @@ func (k *RemoteKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions v := string(item.Content) wire[i].Content = &v } + if item.HasContentPatch { + v := item.ContentPatch + wire[i].ContentPatch = &v + } if item.HasMeta { v := string(item.Meta) wire[i].Meta = &v @@ -333,3 +341,162 @@ func (k *RemoteKeg) DexArtifacts(ctx context.Context) (*DexArtifacts, error) { } return out, nil } + +// trashWireEntry is one GET /trash item. +type trashWireEntry struct { + ID int `json:"id"` + Title string `json:"title"` + Reason string `json:"reason"` + MergedInto *int `json:"merged_into,omitempty"` + TrashedAt time.Time `json:"trashed_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Size int64 `json:"size"` + // DisplacedFrom is the id the entry was trashed at before an archive + // restore moved it. + DisplacedFrom *int `json:"displaced_from,omitempty"` +} + +func (k *RemoteKeg) ListTrash(ctx context.Context) ([]TrashEntry, error) { + var wire struct { + Trash []trashWireEntry `json:"trash"` + } + if err := k.getJSON(ctx, "/trash", "ListTrash", &wire); err != nil { + return nil, err + } + out := make([]TrashEntry, len(wire.Trash)) + for i, item := range wire.Trash { + out[i] = TrashEntry{ID: NodeId{ID: item.ID}, Title: item.Title, Reason: item.Reason, TrashedAt: item.TrashedAt, ExpiresAt: item.ExpiresAt, Size: item.Size} + if item.MergedInto != nil { + out[i].MergedInto = &NodeId{ID: *item.MergedInto} + } + if item.DisplacedFrom != nil { + out[i].DisplacedFrom = &NodeId{ID: *item.DisplacedFrom} + } + } + return out, nil +} + +// TrashInfo finds id in the hub's trash listing; the REST API has no route +// for a single entry. ErrNotExist when id is not trashed. +func (k *RemoteKeg) TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) { + entries, err := k.ListTrash(ctx) + if err != nil { + return TrashEntry{}, err + } + for _, entry := range entries { + if entry.ID.ID == id.ID { + return entry, nil + } + } + return TrashEntry{}, fmt.Errorf("trashed node %s: %w", id.Path(), ErrNotExist) +} + +func (k *RemoteKeg) RestoreTrashed(ctx context.Context, ids []NodeId) error { + return k.postJSON(ctx, "/trash/restore", "RestoreTrashed", struct { + Nodes []int `json:"nodes"` + }{remoteNodeNumbers(ids)}, nil, http.StatusOK) +} + +func (k *RemoteKeg) PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + if opts.ExpiredAt != nil { + // The hub runs its own retention sweep; an expiry purge is local. + return PurgeTrashResult{}, ErrNotSupported + } + var wire struct { + Purged []int `json:"purged"` + Rewritten []int `json:"rewritten"` + } + if err := k.postJSON(ctx, "/trash/purge", "PurgeTrashed", struct { + Nodes []int `json:"nodes"` + }{remoteNodeNumbers(opts.IDs)}, &wire, http.StatusOK); err != nil { + return PurgeTrashResult{}, err + } + out := PurgeTrashResult{Purged: make([]NodeId, len(wire.Purged)), Rewritten: make([]NodeId, len(wire.Rewritten))} + for i, id := range wire.Purged { + out.Purged[i] = NodeId{ID: id} + } + for i, id := range wire.Rewritten { + out.Rewritten[i] = NodeId{ID: id} + } + return out, nil +} + +func remoteNodeNumbers(ids []NodeId) []int { + out := make([]int, len(ids)) + for i, id := range ids { + out[i] = id.ID + } + return out +} + +func (k *RemoteKeg) MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + type wireTarget struct { + NodeID int `json:"node_id"` + Schema string `json:"schema,omitempty"` + Content *string `json:"content,omitempty"` + ContentPatch *string `json:"content_patch,omitempty"` + Meta *string `json:"meta,omitempty"` + ExpectedHash string `json:"expected_hash,omitempty"` + ExpectedContentHash string `json:"expected_content_hash,omitempty"` + ExpectedMetaHash string `json:"expected_meta_hash,omitempty"` + } + target := wireTarget{ + NodeID: opts.Target.ID.ID, + Schema: opts.Target.Schema, + ExpectedHash: opts.Target.ExpectedHash, + ExpectedContentHash: opts.Target.ExpectedContentHash, + ExpectedMetaHash: opts.Target.ExpectedMetaHash, + } + if opts.Target.HasContent { + v := string(opts.Target.Content) + target.Content = &v + } + if opts.Target.HasContentPatch { + v := opts.Target.ContentPatch + target.ContentPatch = &v + } + if opts.Target.HasMeta { + v := string(opts.Target.Meta) + target.Meta = &v + } + var wire struct { + Target struct { + ID int `json:"id"` + Hash string `json:"hash"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` + Validation *SchemaValidationResult `json:"validation,omitempty"` + } `json:"target"` + Rewritten []int `json:"rewritten"` + } + if err := k.postJSON(ctx, "/nodes/merge", "MergeNodes", struct { + Source int `json:"source"` + ExpectedSourceHash string `json:"expected_source_hash"` + Target wireTarget `json:"target"` + }{opts.Source.ID, opts.ExpectedSourceHash, target}, &wire, http.StatusOK); err != nil { + return MergeNodesResult{}, err + } + out := MergeNodesResult{Target: NodeUpdateResult{ID: NodeId{ID: wire.Target.ID}, Hash: wire.Target.Hash, ContentHash: wire.Target.ContentHash, MetaHash: wire.Target.MetaHash, Validation: wire.Target.Validation}} + for _, id := range wire.Rewritten { + out.Rewritten = append(out.Rewritten, NodeId{ID: id}) + } + return out, nil +} + +func (k *RemoteKeg) ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + var wire struct { + Nodes []struct { + ReplacedNode + ID int `json:"id"` + } `json:"nodes"` + } + if err := k.postJSON(ctx, "/nodes/replace", "ReplaceText", opts, &wire, http.StatusOK); err != nil { + return ReplaceTextResult{}, err + } + out := ReplaceTextResult{Nodes: make([]ReplacedNode, len(wire.Nodes))} + for i, item := range wire.Nodes { + out.Nodes[i] = item.ReplacedNode + out.Nodes[i].ID = NodeId{ID: item.ID} + } + return out, nil +} diff --git a/pkg/keg/keg_settings.go b/pkg/keg/keg_settings.go index 4eb14c85..22d59471 100644 --- a/pkg/keg/keg_settings.go +++ b/pkg/keg/keg_settings.go @@ -76,9 +76,11 @@ type SettingsV2 struct { // this keg. Instructions string `yaml:"instructions,omitempty" json:"instructions,omitempty"` - // Links holds a list of LinkEntry objects representing related links or - // references in the configuration. - Links []LinkEntry `yaml:"links,omitempty" json:"links,omitempty"` + // Aliases name same-Hub KEGs (and external URLs) so a note can link + // keg:~alias/N and keep that link when a KEG moves: only the alias's + // target changes. Documents written before the rename call this links; + // it is read when aliases is absent. + Aliases []AliasEntry `yaml:"aliases,omitempty" json:"aliases,omitempty"` // Indexes is a list of index entries that link to related files or nodes. Indexes []IndexEntry `yaml:"indexes,omitempty" json:"indexes,omitempty"` @@ -182,12 +184,17 @@ func formatSnapshotDuration(d time.Duration) string { return d.String() } -// LinkEntry represents a named link in the KEG settings. -type LinkEntry struct { - Alias string `yaml:"alias" json:"alias"` // Alias for the link - URL string `yaml:"url" json:"url"` // URL of the link +// AliasEntry names a KEG, or an external URL, in the KEG settings. +type AliasEntry struct { + Alias string `yaml:"alias" json:"alias"` // the name a keg:~alias/N link uses + URL string `yaml:"url" json:"url"` // keg:@namespace/keg, or an external URL } +// LinkEntry is the old name of AliasEntry. +// +// Deprecated: use AliasEntry. +type LinkEntry = AliasEntry + // IndexEntry represents an entry in the indexes list in the KEG settings. // // File is the bare filename of the generated index artifact, e.g. "backlinks" @@ -216,7 +223,7 @@ func (c *SettingsV1) toV2() *SettingsV2 { Creator: c.Creator, State: c.State, Description: c.Description, - Links: nil, // No links in v1, so leave as nil + Aliases: nil, // No aliases in v1, so leave as nil Indexes: c.Indexes, Snapshots: DefaultSnapshotSettings(), path: "", @@ -486,7 +493,7 @@ func (kc *Settings) Location() *time.Location { } func (kc *Settings) ResolveAlias(alias string) (*Target, error) { - for _, entry := range kc.Links { + for _, entry := range kc.Aliases { if alias == entry.Alias { kt, err := Parse(entry.URL) if err != nil { @@ -553,7 +560,8 @@ func (kc *Settings) Touch(t time.Time) { kc.Updated = t.Format(time.RFC3339) } -// UnmarshalYAML accepts legacy summary only when description is absent. +// UnmarshalYAML accepts legacy summary only when description is absent, and +// legacy links only when aliases is. func (s *SettingsV2) UnmarshalYAML(node *yaml.Node) error { type plain SettingsV2 var value plain @@ -571,11 +579,19 @@ func (s *SettingsV2) UnmarshalYAML(node *yaml.Node) error { } } } + if _, exists := fields["aliases"]; !exists { + if old, ok := fields["links"]; ok { + if err := old.Decode(&value.Aliases); err != nil { + return err + } + } + } *s = SettingsV2(value) return nil } -// UnmarshalJSON accepts legacy summary only when description is absent. +// UnmarshalJSON accepts legacy summary only when description is absent, and +// legacy links only when aliases is. func (s *SettingsV2) UnmarshalJSON(data []byte) error { type plain SettingsV2 var value plain @@ -593,6 +609,13 @@ func (s *SettingsV2) UnmarshalJSON(data []byte) error { } } } + if _, exists := fields["aliases"]; !exists { + if old, ok := fields["links"]; ok { + if err := json.Unmarshal(old, &value.Aliases); err != nil { + return err + } + } + } *s = SettingsV2(value) return nil } diff --git a/pkg/keg/keg_settings_test.go b/pkg/keg/keg_settings_test.go index f83091e6..42e08044 100644 --- a/pkg/keg/keg_settings_test.go +++ b/pkg/keg/keg_settings_test.go @@ -36,7 +36,7 @@ indexes: require.Len(t, userIndexes, 2) require.Equal(t, "index1.md", userIndexes[0].File) require.Equal(t, "index2.md", userIndexes[1].File) - require.Empty(t, settings.Links) + require.Empty(t, settings.Aliases) } func TestParseConfigDataV2(t *testing.T) { @@ -73,9 +73,9 @@ schemaPolicy: require.Equal(t, "Test KEG V2", settings.Title) require.Equal(t, "Use this KEG for parser tests.", settings.Instructions) - require.Len(t, settings.Links, 2, "expected 2 links") + require.Len(t, settings.Aliases, 2, "expected 2 links") links := map[string]string{} - for _, l := range settings.Links { + for _, l := range settings.Aliases { links[l.Alias] = l.URL } require.Contains(t, links, "home") @@ -452,3 +452,31 @@ func indexFiles(entries []keg.IndexEntry) []string { } return out } + +// Settings written before the rename call aliases links. They are still +// read, in YAML and JSON, but only when aliases is absent, and the +// settings are written back under aliases alone. +func TestSettingsReadLegacyLinksAsAliases(t *testing.T) { + legacy := "kegv: \"2025-07\"\ntitle: T\nlinks:\n - alias: docs\n url: keg:@team/manual\n" + settings, err := keg.ParseKegSettings([]byte(legacy)) + require.NoError(t, err) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}}, settings.Aliases) + + out, err := settings.ToYAML() + require.NoError(t, err) + require.Contains(t, string(out), "aliases:") + require.NotContains(t, string(out), "links:") + + both := legacy + "aliases:\n - alias: kb\n url: keg:@team/kb\n" + settings, err = keg.ParseKegSettings([]byte(both)) + require.NoError(t, err) + require.Equal(t, []keg.AliasEntry{{Alias: "kb", URL: "keg:@team/kb"}}, settings.Aliases, "aliases wins over legacy links") + + var fromJSON keg.Settings + require.NoError(t, json.Unmarshal([]byte(`{"kegv":"2025-07","links":[{"alias":"docs","url":"keg:@team/manual"}]}`), &fromJSON)) + require.Equal(t, []keg.AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}}, fromJSON.Aliases) + encoded, err := fromJSON.ToJSON() + require.NoError(t, err) + require.Contains(t, string(encoded), `"aliases":`) + require.NotContains(t, string(encoded), `"links":`) +} diff --git a/pkg/keg/merge.go b/pkg/keg/merge.go new file mode 100644 index 00000000..5eb656a6 --- /dev/null +++ b/pkg/keg/merge.go @@ -0,0 +1,83 @@ +package keg + +import ( + "context" + "fmt" +) + +// MergeNodesOptions folds Source into Target: Target is rewritten with the +// merged body, every link to Source is pointed at Target, and Source is +// removed (to the trash, where the keg keeps one). +type MergeNodesOptions struct { + // Source is the node merged away, guarded by its combined hash. + Source NodeId `json:"source"` + ExpectedSourceHash string `json:"expected_source_hash"` + // Target is the surviving node's update. A merge is a rewrite, so it + // must carry content or a content patch; meta is optional, for combining + // tags and fields. Its part tokens guard it as for any update. + Target NodeUpdateOptions `json:"target"` +} + +// MergeNodesResult is the surviving node's tokens after the merge (links in +// it may have been repointed too), and the other nodes whose links moved. +type MergeNodesResult struct { + Target NodeUpdateResult `json:"target"` + Rewritten []NodeId `json:"rewritten"` +} + +// MergeNodes folds opts.Source into opts.Target as one write: either all of +// it happens or none of it does. +func (k *LocalKeg) MergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (MergeNodesResult, error) { return k.mergeNodes(ctx, opts) }) +} + +func (k *LocalKeg) mergeNodes(ctx context.Context, opts MergeNodesOptions) (MergeNodesResult, error) { + source, target := opts.Source, opts.Target.ID + if !source.Valid() || !target.Valid() { + return MergeNodesResult{}, fmt.Errorf("merge needs a source and a target node: %w", ErrInvalid) + } + if source.ID == 0 || target.ID == 0 { + return MergeNodesResult{}, fmt.Errorf("node 0 cannot be merged: %w", ErrInvalid) + } + if source.Equals(target) { + return MergeNodesResult{}, fmt.Errorf("a node cannot be merged into itself: %w", ErrInvalid) + } + if !opts.Target.HasContent && !opts.Target.HasContentPatch { + return MergeNodesResult{}, fmt.Errorf("a merge rewrites the target: send its merged content or a content patch: %w", ErrInvalid) + } + current, err := k.ReadNode(ctx, source) + if err != nil { + return MergeNodesResult{}, fmt.Errorf("merge source %s: %w", source.Path(), err) + } + if err := checkExpectedHash("node "+source.Path(), opts.ExpectedSourceHash, current.Hash(), nodeRecoveryContent(current)); err != nil { + return MergeNodesResult{}, err + } + results, err := k.updateNodes(ctx, []NodeUpdateOptions{opts.Target}) + if err != nil { + return MergeNodesResult{}, err + } + // The source's own links to itself stay as they are, so a restored + // source comes back exactly as it was trashed. + rewritten, err := k.retargetLinks(ctx, source, target, source) + if err != nil { + return MergeNodesResult{}, err + } + if _, err := k.removeUnchecked(ctx, source, trashInfo{reason: TrashReasonMerge, mergedInto: &target}); err != nil { + return MergeNodesResult{}, err + } + // The target's own links to the source were repointed too, so read its + // tokens back rather than trusting the update's. + merged, err := k.ReadNode(ctx, target) + if err != nil { + return MergeNodesResult{}, err + } + result := MergeNodesResult{ + Target: NodeUpdateResult{ID: target, Validation: results[0].Validation, Hash: merged.Hash(), ContentHash: merged.ContentHash(), MetaHash: merged.MetaHash()}, + } + for _, id := range rewritten { + if !id.Equals(target) && !id.Equals(source) { + result.Rewritten = append(result.Rewritten, id) + } + } + return result, nil +} diff --git a/pkg/keg/orientation_test.go b/pkg/keg/orientation_test.go index 020b330a..9049f10f 100644 --- a/pkg/keg/orientation_test.go +++ b/pkg/keg/orientation_test.go @@ -13,7 +13,7 @@ type orientationRoundTripFunc func(*http.Request) (*http.Response, error) func (f orientationRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Path == "/api/version" { - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-09-28"]}`))}, nil + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-10-12"]}`))}, nil } return f(req) } diff --git a/pkg/keg/patch.go b/pkg/keg/patch.go new file mode 100644 index 00000000..d47da2d8 --- /dev/null +++ b/pkg/keg/patch.go @@ -0,0 +1,398 @@ +package keg + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// maxPatchBytes caps a content patch. A patch that size is already far +// larger than any note it could sensibly edit; send content instead. +const maxPatchBytes = 1 << 20 + +// maxPatchComparisons caps the line comparisons spent placing a patch's +// hunks, so a patch whose lines repeat throughout a large note cannot keep a +// write busy for long. +const maxPatchComparisons = 20_000_000 + +// ApplyUnifiedPatch applies a unified diff to base and returns the patched +// text. +// +// The patch is the hunk part of a `diff -u`: optional `diff`/`---`/`+++` +// header lines, then `@@ -a,b +c,d @@` hunks of context (" "), removed ("-") +// and added ("+") lines. A bare empty line inside a hunk counts as an empty +// context line, since editors and models often strip the single space. +// +// Content is never guessed at: each hunk's context and removed lines must +// appear in base exactly. The hunk header's line number is only a hint, so a +// hunk whose numbers are off still applies when its lines match in exactly +// one place; its counts only decide where a hunk may end, so a removed +// "-- x" line followed by an added "++ y" line is not mistaken for another +// file's header. A hunk that matches nowhere, or in more than one place none +// of which is at its stated line, is refused with ErrInvalid. Hunks apply in +// order and may not overlap. +// +// Line endings are kept: a base with CRLF line endings stays CRLF, and the +// patch's lines match base lines whatever their ending. "\ No newline at end +// of file" may only follow a hunk that reaches the end of the note. +func ApplyUnifiedPatch(base []byte, patch string) ([]byte, error) { + if len(patch) > maxPatchBytes { + return nil, fmt.Errorf("patch is %d bytes; the limit is %d, so send the content instead: %w", len(patch), maxPatchBytes, ErrInvalid) + } + hunks, err := parseUnifiedPatch(patch) + if err != nil { + return nil, err + } + lines, eofNewline := splitPatchLines(string(base)) + baseEOF := eofNewline + // Untouched lines keep their own endings, so a mixed body stays as it + // was. A line a hunk writes takes the ending of the line it replaces, or + // the body's predominant ending. + crlf := predominantCRLF(lines, eofNewline) + ending := func(line string, fallback bool) string { + if fallback { + return line + "\r" + } + return line + } + // lastBase is where base's final line landed in out when base had no + // final newline: if a line follows it now, it needs an ending. + lastBase := -1 + keep := func(out []string, i int) []string { + if i == len(lines)-1 && !baseEOF { + lastBase = len(out) + } + return append(out, lines[i]) + } + m := &patchMatcher{lines: lines, budget: maxPatchComparisons} + out := make([]string, 0, len(lines)) + cursor := 0 + for i, h := range hunks { + old := h.oldLines() + at, err := m.locateHunk(old, cursor, h.oldStart) + if err != nil { + return nil, fmt.Errorf("patch hunk %d: %w", i+1, err) + } + if (h.oldNoNewline || h.newNoNewline) && at+len(old) != len(lines) { + return nil, fmt.Errorf("patch hunk %d marks 'No newline at end of file' but does not reach the end of the note: %w", i+1, ErrInvalid) + } + for j := cursor; j < at; j++ { + out = keep(out, j) + } + j := at + // replaced is the ending of the last line this run removed, if any. + replaced := -1 + for _, l := range h.lines { + switch l.op { + case ' ': + out = keep(out, j) + j++ + replaced = -1 + case '-': + switch { + case j == len(lines)-1 && !baseEOF: + // Base's last line has no ending to pass on. + replaced = -1 + case strings.HasSuffix(lines[j], "\r"): + replaced = 1 + default: + replaced = 0 + } + j++ + case '+': + withCR := crlf + if replaced >= 0 { + withCR = replaced == 1 + } + out = append(out, ending(l.text, withCR)) + } + } + cursor = at + len(old) + if h.oldNoNewline || h.newNoNewline { + eofNewline = !h.newNoNewline + } + } + for j := cursor; j < len(lines); j++ { + out = keep(out, j) + } + if len(out) == 0 { + return []byte{}, nil + } + if lastBase >= 0 && (lastBase < len(out)-1 || eofNewline) && crlf && !strings.HasSuffix(out[lastBase], "\r") { + out[lastBase] += "\r" + } + if !eofNewline { + // No newline follows the last line, so it carries no "\r" either. + out[len(out)-1] = strings.TrimSuffix(out[len(out)-1], "\r") + } + text := strings.Join(out, "\n") + if eofNewline { + text += "\n" + } + return []byte(text), nil +} + +// predominantCRLF reports whether most of the body's line breaks are CRLF, +// the first line deciding a tie. lines are split on "\n"; the last has no +// break after it unless eofNewline. +func predominantCRLF(lines []string, eofNewline bool) bool { + breaks := len(lines) + if !eofNewline { + breaks-- + } + crlf := 0 + for i := 0; i < breaks; i++ { + if strings.HasSuffix(lines[i], "\r") { + crlf++ + } + } + lf := breaks - crlf + if crlf != lf { + return crlf > lf + } + return breaks > 0 && strings.HasSuffix(lines[0], "\r") +} + +type patchLine struct { + op byte + text string +} + +type patchHunk struct { + // oldStart is the 1-based line the hunk says it starts at, or 0 when the + // header carries no usable number. + oldStart int + // oldCount and newCount are the header's line counts; counted says the + // header had them. + oldCount, newCount int + counted bool + lines []patchLine + oldNoNewline bool + newNoNewline bool +} + +// complete reports whether the hunk already holds as many lines as its +// header declares. +func (h patchHunk) complete() bool { + if !h.counted { + return false + } + oldSeen, newSeen := 0, 0 + for _, l := range h.lines { + if l.op != '+' { + oldSeen++ + } + if l.op != '-' { + newSeen++ + } + } + return oldSeen >= h.oldCount && newSeen >= h.newCount +} + +func (h patchHunk) oldLines() []string { + var out []string + for _, l := range h.lines { + if l.op != '+' { + out = append(out, l.text) + } + } + return out +} + +var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) + +func parseUnifiedPatch(patch string) ([]patchHunk, error) { + patch = strings.ReplaceAll(patch, "\r\n", "\n") + if strings.TrimSpace(patch) == "" { + return nil, fmt.Errorf("patch is empty: %w", ErrInvalid) + } + // Trailing blank lines are padding, not empty context lines. + raw := strings.Split(strings.TrimRight(patch, "\n"), "\n") + var hunks []patchHunk + var cur *patchHunk + for n, line := range raw { + if strings.HasPrefix(line, "@@") { + h := patchHunk{} + if m := hunkHeaderPattern.FindStringSubmatch(line); m != nil { + h.oldStart, _ = strconv.Atoi(m[1]) + // "-a,0" names the line an insertion follows, so the + // insertion starts one line later. + if m[2] == "0" { + h.oldStart++ + } + h.oldCount, h.newCount, h.counted = 1, 1, true + if m[2] != "" { + h.oldCount, _ = strconv.Atoi(m[2]) + } + if m[4] != "" { + h.newCount, _ = strconv.Atoi(m[4]) + } + } + hunks = append(hunks, h) + cur = &hunks[len(hunks)-1] + continue + } + if cur == nil { + if isPatchHeader(line) || strings.TrimSpace(line) == "" { + continue + } + return nil, fmt.Errorf("patch line %d: expected a hunk header (@@ -a,b +c,d @@) before %q: %w", n+1, line, ErrInvalid) + } + if line == "" { + cur.lines = append(cur.lines, patchLine{op: ' '}) + continue + } + // A line that looks like the next file's header only is one once + // the hunk holds all the lines its header declared; before that it + // is a removed "-- x" or an added "++ y" line. A header without + // counts gives nothing to go on, so the look alone decides. + if (!cur.counted || cur.complete()) && + (strings.HasPrefix(line, "diff ") || (strings.HasPrefix(line, "--- ") && n+1 < len(raw) && strings.HasPrefix(raw[n+1], "+++ "))) { + return nil, fmt.Errorf("patch line %d: a patch edits one note; %q starts another file: %w", n+1, line, ErrInvalid) + } + switch line[0] { + case ' ', '-', '+': + cur.lines = append(cur.lines, patchLine{op: line[0], text: line[1:]}) + case '\\': + if len(cur.lines) == 0 { + return nil, fmt.Errorf("patch line %d: %q follows no line: %w", n+1, line, ErrInvalid) + } + switch cur.lines[len(cur.lines)-1].op { + case ' ': + cur.oldNoNewline, cur.newNoNewline = true, true + case '-': + cur.oldNoNewline = true + case '+': + cur.newNoNewline = true + } + default: + return nil, fmt.Errorf("patch line %d: %q must start with ' ', '-' or '+': %w", n+1, line, ErrInvalid) + } + } + if len(hunks) == 0 { + return nil, fmt.Errorf("patch has no hunks (@@ -a,b +c,d @@): %w", ErrInvalid) + } + for i, h := range hunks { + changed := false + for _, l := range h.lines { + if l.op != ' ' { + changed = true + break + } + } + if !changed { + return nil, fmt.Errorf("patch hunk %d changes nothing: %w", i+1, ErrInvalid) + } + } + return hunks, nil +} + +func isPatchHeader(line string) bool { + return strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") || + strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index ") +} + +// splitPatchLines splits text into lines without their "\n" and reports +// whether the text ended with one. A CRLF line keeps its "\r". +func splitPatchLines(text string) ([]string, bool) { + if text == "" { + return nil, true + } + eofNewline := strings.HasSuffix(text, "\n") + return strings.Split(strings.TrimSuffix(text, "\n"), "\n"), eofNewline +} + +// patchMatcher places hunks in a note's lines within a comparison budget. +type patchMatcher struct { + lines []string + budget int + exceeded bool +} + +var errPatchTooExpensive = fmt.Errorf("this patch is too expensive to place; add context lines or send content: %w", ErrInvalid) + +// locateHunk finds where old starts in the lines at or after from. The +// stated line wins when old is there; otherwise old must occur exactly once. +func (m *patchMatcher) locateHunk(old []string, from, oldStart int) (int, error) { + lines := m.lines + if len(old) == 0 { + // A pure insertion has nothing to match, so its stated line is all + // there is to go on. + at := oldStart - 1 + if oldStart == 0 || at < from || at > len(lines) { + return 0, fmt.Errorf("an insertion with no context lines needs a valid line number in its header: %w", ErrInvalid) + } + return at, nil + } + if at := oldStart - 1; at >= from && m.matchesAt(old, at) { + return at, nil + } + found := -1 + count := 0 + for at := from; at+len(old) <= len(lines) && !m.exceeded; at++ { + if m.matchesAt(old, at) { + if found < 0 { + found = at + } + count++ + } + } + if m.exceeded { + return 0, errPatchTooExpensive + } + switch { + case count == 1: + return found, nil + case count == 0 && m.occursBefore(old, from): + return 0, fmt.Errorf("its lines only occur before the end of the previous hunk; hunks must be in order and must not overlap: %w", ErrInvalid) + case count == 0 && m.exceeded: + return 0, errPatchTooExpensive + case count == 0: + return 0, fmt.Errorf("its context and removed lines do not match the current content (did the note change, or is a context line wrong?); expected:\n%s: %w", quotePatchLines(old), ErrInvalid) + default: + return 0, fmt.Errorf("its context and removed lines match %d places and none is at its stated line; add context lines to make it unique: %w", count, ErrInvalid) + } +} + +func (m *patchMatcher) occursBefore(old []string, end int) bool { + for at := 0; at < end && !m.exceeded; at++ { + if m.matchesAt(old, at) { + return true + } + } + return false +} + +// matchesAt reports whether old matches the lines starting at at, ignoring +// each line's "\r". It spends one comparison per line it compares. +func (m *patchMatcher) matchesAt(old []string, at int) bool { + lines := m.lines + if at < 0 || at+len(old) > len(lines) { + return false + } + for i, l := range old { + if m.budget <= 0 { + m.exceeded = true + return false + } + m.budget-- + if strings.TrimSuffix(lines[at+i], "\r") != l { + return false + } + } + return true +} + +func quotePatchLines(lines []string) string { + const max = 8 + var b strings.Builder + for i, l := range lines { + if i == max { + fmt.Fprintf(&b, " … %d more\n", len(lines)-max) + break + } + fmt.Fprintf(&b, " %q\n", l) + } + return strings.TrimSuffix(b.String(), "\n") +} diff --git a/pkg/keg/patch_test.go b/pkg/keg/patch_test.go new file mode 100644 index 00000000..70ae16e6 --- /dev/null +++ b/pkg/keg/patch_test.go @@ -0,0 +1,272 @@ +package keg_test + +import ( + "strings" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func TestApplyUnifiedPatch(t *testing.T) { + t.Parallel() + base := "# Title\n\nalpha\nbeta\ngamma\n\n## Status\nDraft\n" + cases := []struct { + name string + base string + patch string + want string + }{ + { + name: "replaces a line", + base: base, + patch: "@@ -7,2 +7,2 @@\n ## Status\n-Draft\n+Published\n", + want: "# Title\n\nalpha\nbeta\ngamma\n\n## Status\nPublished\n", + }, + { + name: "accepts file headers", + base: base, + patch: "--- a/README.md\n+++ b/README.md\n@@ -3,1 +3,1 @@\n-alpha\n+ALPHA\n", + want: "# Title\n\nALPHA\nbeta\ngamma\n\n## Status\nDraft\n", + }, + { + name: "applies when the stated line is wrong but the lines are unique", + base: base, + patch: "@@ -40,3 +40,3 @@\n alpha\n-beta\n+BETA\n gamma\n", + want: "# Title\n\nalpha\nBETA\ngamma\n\n## Status\nDraft\n", + }, + { + name: "applies several hunks in order", + base: base, + patch: "@@ -1 +1 @@\n-# Title\n+# New title\n@@ -8 +8,2 @@\n-Draft\n+Published\n+On 2026-10-10\n", + want: "# New title\n\nalpha\nbeta\ngamma\n\n## Status\nPublished\nOn 2026-10-10\n", + }, + { + name: "reads a bare empty line as empty context", + base: base, + patch: "@@ -5,3 +5,3 @@\n gamma\n\n-## Status\n+## State\n", + want: "# Title\n\nalpha\nbeta\ngamma\n\n## State\nDraft\n", + }, + { + name: "inserts after a stated line with no context", + base: "one\ntwo\n", + patch: "@@ -1,0 +2 @@\n+one and a half\n", + want: "one\none and a half\ntwo\n", + }, + { + name: "drops the final newline when told to", + base: "one\ntwo\n", + patch: "@@ -2 +2 @@\n-two\n+two\n\\ No newline at end of file\n", + want: "one\ntwo", + }, + { + name: "adds a final newline when the old side lacked one", + base: "one\ntwo", + patch: "@@ -2 +2 @@\n-two\n\\ No newline at end of file\n+two\n", + want: "one\ntwo\n", + }, + { + name: "uses the stated line when the lines repeat", + base: "x\ny\nx\ny\n", + patch: "@@ -3,2 +3,2 @@\n x\n-y\n+z\n", + want: "x\ny\nx\nz\n", + }, + { + name: "empties a note", + base: "only\n", + patch: "@@ -1 +0,0 @@\n-only\n", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := keg.ApplyUnifiedPatch([]byte(tc.base), tc.patch) + require.NoError(t, err) + require.Equal(t, tc.want, string(got)) + }) + } +} + +func TestApplyUnifiedPatchRefusesWhatItCannotPlace(t *testing.T) { + t.Parallel() + base := "x\ny\nx\ny\n" + cases := []struct { + name string + patch string + contains string + }{ + {name: "empty", patch: " \n", contains: "empty"}, + {name: "no hunks", patch: "--- a\n+++ b\n", contains: "no hunks"}, + {name: "text before a hunk", patch: "change y\n@@ -2 +2 @@\n-y\n+z\n", contains: "hunk header"}, + {name: "context that is not there", patch: "@@ -1,2 +1,2 @@\n x\n-q\n+z\n", contains: "do not match"}, + {name: "ambiguous lines away from the stated line", patch: "@@ -9,2 +9,2 @@\n x\n-y\n+z\n", contains: "match 2 places"}, + {name: "overlapping hunks", patch: "@@ -1,2 +1,2 @@\n-x\n+w\n y\n@@ -2,1 +2,1 @@\n-y\n+v\n@@ -1 +1 @@\n-x\n+u\n", contains: "hunk 3: its lines only occur before"}, + {name: "a hunk that changes nothing", patch: "@@ -1 +1 @@\n x\n", contains: "changes nothing"}, + {name: "a second file", patch: "@@ -1 +1 @@\n-x\n+z\n--- a/other\n+++ b/other\n", contains: "another file"}, + {name: "an insertion with no line", patch: "@@ @@\n+new\n", contains: "line number"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte(base), tc.patch) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, tc.contains) + }) + } +} + +func TestLocalKegUpdateNodesAppliesContentPatch(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Note\n\nDraft\n")}) + require.NoError(t, err) + original, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + + results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Published\n", HasContentPatch: true, ExpectedContentHash: original.ContentHash(), + }}) + require.NoError(t, err) + patched, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Note\n\nPublished\n", string(patched.Content)) + require.Equal(t, patched.ContentHash(), results[0].ContentHash) + + // A stale token is a conflict, even though the patch would no longer + // fit either: the caller learns the note moved on. + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Final\n", HasContentPatch: true, ExpectedContentHash: original.ContentHash(), + }}) + var conflict *keg.PreconditionConflictError + require.ErrorAs(t, err, &conflict) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, Content: []byte("# X\n"), HasContent: true, ContentPatch: "@@ -1 +1 @@\n-# Note\n+# X\n", HasContentPatch: true, ExpectedContentHash: patched.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid) + + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -1 +1,3 @@\n-# Note\n+---\n+tags: [x]\n+---\n", HasContentPatch: true, ExpectedContentHash: patched.ContentHash(), + }}) + require.Error(t, err, "a patch may not add frontmatter") + unchanged, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, patched.Content, unchanged.Content) +} + +func TestApplyUnifiedPatchKeepsLineEndingsAndCounts(t *testing.T) { + t.Parallel() + cases := []struct { + name string + base string + patch string + want string + }{ + { + name: "keeps CRLF line endings", + base: "a\r\nb\r\nc\r\n", + patch: "@@ -2 +2 @@\n-b\n+B\n", + want: "a\r\nB\r\nc\r\n", + }, + { + name: "keeps CRLF when adding lines at the end", + base: "a\r\nb\r\n", + patch: "@@ -2 +2,2 @@\n b\n+c\n", + want: "a\r\nb\r\nc\r\n", + }, + { + name: "keeps each untouched line's own ending in a mixed body", + base: "a\r\nb\n", + patch: "@@ -1 +1 @@\n-a\n+A\n", + want: "A\r\nb\n", + }, + { + name: "gives a former last line the body's ending when a line follows it", + base: "a\r\nb", + patch: "@@ -1,2 +1,3 @@\n a\n-b\n\\ No newline at end of file\n+b\n+c\n\\ No newline at end of file\n", + want: "a\r\nb\r\nc", + }, + { + name: "reads header-looking lines inside a hunk by its counts", + base: "x\n-- old\ny\n", + patch: "@@ -1,3 +1,3 @@\n x\n--- old\n+++ new\n y\n", + want: "x\n++ new\ny\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := keg.ApplyUnifiedPatch([]byte(tc.base), tc.patch) + require.NoError(t, err) + require.Equal(t, tc.want, string(got)) + }) + } +} + +func TestApplyUnifiedPatchRefusesBadMarkersAndCostlyPatches(t *testing.T) { + t.Parallel() + t.Run("no-newline marker on a hunk that does not reach the end", func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte("one\ntwo\nthree\n"), "@@ -1 +1 @@\n-one\n+ONE\n\\ No newline at end of file\n") + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "No newline") + }) + t.Run("a second file after a complete hunk", func(t *testing.T) { + t.Parallel() + _, err := keg.ApplyUnifiedPatch([]byte("x\ny\n"), "@@ -1 +1 @@\n-x\n+z\n--- a/other\n+++ b/other\n@@ -1 +1 @@\n-q\n+r\n") + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "another file") + }) + t.Run("a patch too costly to place", func(t *testing.T) { + t.Parallel() + base := strings.Repeat("a\n", 200_000) + var patch strings.Builder + patch.WriteString("@@ -1,50001 +1,50001 @@\n") + for range 50_000 { + patch.WriteString(" a\n") + } + patch.WriteString("-b\n+c\n") + _, err := keg.ApplyUnifiedPatch([]byte(base), patch.String()) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "too expensive") + }) + t.Run("a patch over the size limit", func(t *testing.T) { + t.Parallel() + patch := "@@ -1 +1 @@\n-a\n+" + strings.Repeat("b", 1<<20) + "\n" + _, err := keg.ApplyUnifiedPatch([]byte("a\n"), patch) + require.ErrorIs(t, err, keg.ErrInvalid) + }) +} + +func TestUpdateNodesPatchKeepsCRLF(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Note\n")}) + require.NoError(t, err) + require.NoError(t, k.Repo.WriteContent(ctx, created.ID, []byte("# Note\r\n\r\nDraft\r\n"))) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -3 +3 @@\n-Draft\n+Published\n", HasContentPatch: true, ExpectedContentHash: view.ContentHash(), + }}) + require.NoError(t, err) + got, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Note\r\n\r\nPublished\r\n", string(got.Content)) + + // A marker on a hunk away from the end changes nothing. + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, ContentPatch: "@@ -1 +1 @@\n-# Note\n+# Title\n\\ No newline at end of file\n", HasContentPatch: true, ExpectedContentHash: got.ContentHash(), + }}) + require.ErrorIs(t, err, keg.ErrInvalid) + unchanged, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, got.Content, unchanged.Content) +} diff --git a/pkg/keg/relationships.go b/pkg/keg/relationships.go index 5c76e4fb..167de7f3 100644 --- a/pkg/keg/relationships.go +++ b/pkg/keg/relationships.go @@ -9,7 +9,8 @@ import ( var relationshipRefRE = regexp.MustCompile(`^@[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$`) // RelationshipTarget returns the canonical same-Hub KEG reference of a -// settings relationship. Ordinary external URLs are metadata only. +// settings alias or flight cover target. Ordinary external URLs are metadata +// only. func RelationshipTarget(raw string) (string, bool, error) { ref := strings.TrimPrefix(raw, "keg:") if !strings.HasPrefix(raw, "keg:") && !strings.HasPrefix(raw, "@") { @@ -21,8 +22,8 @@ func RelationshipTarget(raw string) (string, bool, error) { return ref, true, nil } -// ValidateRelationships requires unique aliases and canonical KEG targets. -func ValidateRelationships(links []LinkEntry) error { +// ValidateAliases requires unique aliases and canonical KEG targets. +func ValidateAliases(links []AliasEntry) error { seen := map[string]bool{} for _, link := range links { if link.Alias != "" { @@ -41,10 +42,10 @@ func ValidateRelationships(links []LinkEntry) error { return nil } -// ResolveRelationshipAlias resolves the explicit keg:~alias/node form using +// ResolveSettingsAlias resolves the explicit keg:~alias/node form using // only the source KEG's settings, preserving canonical keg:@namespace/keg/node. -func ResolveRelationshipAlias(links []LinkEntry, alias string) (namespace, name string, err error) { - if err = ValidateRelationships(links); err != nil { +func ResolveSettingsAlias(links []AliasEntry, alias string) (namespace, name string, err error) { + if err = ValidateAliases(links); err != nil { return } for _, link := range links { diff --git a/pkg/keg/relationships_test.go b/pkg/keg/relationships_test.go index 6d1ab3a5..2156d988 100644 --- a/pkg/keg/relationships_test.go +++ b/pkg/keg/relationships_test.go @@ -1,19 +1,22 @@ package keg -import "testing" +import ( + "strings" + "testing" +) func TestRelationshipAliasRenderingAndParsing(t *testing.T) { - links := []LinkEntry{{Alias: "docs", URL: "keg:@team/manual"}} + links := []AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}} ref, err := ParseNodeRef("keg:~docs/12") if err != nil || ref.Form != RefSettingsAlias || ref.String() != "keg:~docs/12" { t.Fatalf("parse: %+v %v", ref, err) } - opts := RenderOptions{Links: links, KegResolver: func(ns, alias, id string) string { return "/@" + ns + "/" + alias + "/" + id }} + opts := RenderOptions{Aliases: links, KegResolver: func(ns, alias, id string) string { return "/@" + ns + "/" + alias + "/" + id }} got, ok := ResolveNodeLink("keg:~docs/12#heading", opts) if !ok || got != "/@team/manual/12#heading" { t.Fatalf("resolved: %q %t", got, ok) } - opts.Links[0].URL = "keg:@team/renamed" + opts.Aliases[0].URL = "keg:@team/renamed" got, ok = ResolveNodeLink("keg:~docs/12", opts) if !ok || got != "/@team/renamed/12" { t.Fatalf("remapped: %q %t", got, ok) @@ -21,7 +24,7 @@ func TestRelationshipAliasRenderingAndParsing(t *testing.T) { if _, ok = ResolveNodeLink("keg:~missing/12", opts); ok { t.Fatal("missing alias resolved") } - if err := ValidateRelationships(append(links, links[0])); err == nil { + if err := ValidateAliases(append(links, links[0])); err == nil { t.Fatal("duplicate aliases accepted") } if _, _, err := RelationshipTarget("keg:manual"); err == nil { @@ -43,3 +46,25 @@ func TestOrientationStateNeedsScopeButNotRevision(t *testing.T) { t.Fatalf("round trip: %+v %v", decoded, err) } } + +// A keg: link outside the syntax renders as a dead link and links nothing, +// so it is reported, with the qualified form when it is a cross-KEG link +// missing its @. A ~alias the settings do not name is reported too; a link +// to a KEG's root node, keg:@ns/keg/0, is valid. +func TestKegLinkProblems(t *testing.T) { + aliases := []AliasEntry{{Alias: "docs", URL: "keg:@team/manual"}} + src := []byte("# N\n\n" + + "[guide](keg:outsider/guide/1) [again](keg:outsider/guide/1) " + + "[odd](keg:nothing) [root](keg:@team/manual/0) [local](keg:other/4) " + + "[docs](keg:~docs/2) [missing](keg:~gone/3) [web](https://example.com)\n") + problems := KegLinkProblems(src, aliases) + if len(problems) != 3 { + t.Fatalf("problems = %q, want three", problems) + } + if !strings.Contains(problems[0], "keg:@outsider/guide/1") { + t.Fatalf("unqualified link = %q, want the qualified form suggested", problems[0]) + } + if !strings.Contains(problems[1], `"keg:nothing"`) || !strings.Contains(problems[2], `"keg:~gone/3"`) { + t.Fatalf("problems = %q", problems) + } +} diff --git a/pkg/keg/remote_precondition_test.go b/pkg/keg/remote_precondition_test.go index e1e07347..0459297b 100644 --- a/pkg/keg/remote_precondition_test.go +++ b/pkg/keg/remote_precondition_test.go @@ -94,3 +94,23 @@ func TestRemoteKegUpdateNodesSendsPartTokensAndDecodesPartConflicts(t *testing.T require.NotContains(t, update, "expected_meta_hash") require.NotContains(t, update, "expected_hash") } + +func TestRemoteKegUpdateNodeSendsAPatchWithoutContent(t *testing.T) { + t.Parallel() + var body map[string]any + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"id":3,"hash":"h"}]}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + _, _ = rk.UpdateNode(context.Background(), keg.NodeUpdateOptions{ + ID: keg.NodeId{ID: 3}, ContentPatch: "@@ -1 +1 @@\n-a\n+b\n", HasContentPatch: true, ExpectedContentHash: "c1", + }) + updates := body["updates"].([]any) + require.Len(t, updates, 1) + update := updates[0].(map[string]any) + require.Equal(t, "@@ -1 +1 @@\n-a\n+b\n", update["content_patch"]) + require.NotContains(t, update, "content", "a patch-only update sends no content") +} diff --git a/pkg/keg/remote_trash_test.go b/pkg/keg/remote_trash_test.go new file mode 100644 index 00000000..6111bff3 --- /dev/null +++ b/pkg/keg/remote_trash_test.go @@ -0,0 +1,37 @@ +package keg_test + +import ( + "context" + "net/http" + "testing" + + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +// A remote keg answers TrashInfo from the hub's trash listing: the entry for +// a trashed id, ErrNotExist for any other. +func TestRemoteKegTrashInfo(t *testing.T) { + t.Parallel() + srv := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/trash" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"trash":[{"id":7,"title":"Gone","reason":"merge","merged_into":3,"trashed_at":"2026-01-02T03:04:05Z","size":12}]}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + ctx := context.Background() + + info, err := rk.TrashInfo(ctx, keg.NodeId{ID: 7}) + require.NoError(t, err) + require.Equal(t, "Gone", info.Title) + require.Equal(t, keg.TrashReasonMerge, info.Reason) + require.Equal(t, &keg.NodeId{ID: 3}, info.MergedInto) + + _, err = rk.TrashInfo(ctx, keg.NodeId{ID: 8}) + require.ErrorIs(t, err, keg.ErrNotExist) +} diff --git a/pkg/keg/render.go b/pkg/keg/render.go index c4474848..ad20678f 100644 --- a/pkg/keg/render.go +++ b/pkg/keg/render.go @@ -19,8 +19,8 @@ import ( // RenderOptions configures markdown-to-HTML rendering. type RenderOptions struct { - // Links supplies the source KEG settings aliases. - Links []LinkEntry + // Aliases supplies the source KEG's settings aliases, for keg:~alias/N. + Aliases []AliasEntry // BaseURL is the keg-root URL prefix for rewritten node links; node N // lives at {BaseURL}N. Defaults to "/". BaseURL string @@ -108,7 +108,7 @@ func ResolveNodeLink(dest string, opts RenderOptions) (string, bool) { return "", false } var err error - namespace, alias, err = ResolveRelationshipAlias(opts.Links, strings.TrimPrefix(alias, "~")) + namespace, alias, err = ResolveSettingsAlias(opts.Aliases, strings.TrimPrefix(alias, "~")) if err != nil { return "", false } @@ -198,3 +198,45 @@ func (t *nodeLinkTransformer) Transform(doc *ast.Document, reader text.Reader, p return ast.WalkContinue, nil }) } + +// unqualifiedKegLinkRE matches keg:NS/KEG/N, a cross-KEG link missing the @ +// that marks its namespace. +var unqualifiedKegLinkRE = regexp.MustCompile(`^keg:([A-Za-z0-9][A-Za-z0-9_-]*)/([A-Za-z0-9][A-Za-z0-9_-]*)/([0-9]+)`) + +// KegLinkProblems reports each keg: link in markdown src that cannot be +// followed: one outside the keg: syntax, which renders as a dead link and +// links nothing, or a keg:~alias/N whose alias aliases does not name. Each +// problem is reported once, in order of first appearance. +func KegLinkProblems(src []byte, aliases []AliasEntry) []string { + doc := goldmark.New().Parser().Parse(text.NewReader(src)) + var problems []string + seen := map[string]bool{} + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + link, ok := n.(*ast.Link) + if !entering || !ok { + return ast.WalkContinue, nil + } + dest := strings.TrimSpace(string(link.Destination)) + if !strings.HasPrefix(dest, "keg:") || seen[dest] { + return ast.WalkContinue, nil + } + seen[dest] = true + m := kegSchemeRE.FindStringSubmatch(dest) + switch { + case m == nil: + if u := unqualifiedKegLinkRE.FindStringSubmatch(dest); u != nil { + problems = append(problems, fmt.Sprintf("link %q is not a keg link; to link another KEG write keg:@%s/%s/%s", dest, u[1], u[2], u[3])) + } else { + problems = append(problems, fmt.Sprintf("link %q is not a keg link; write keg:@namespace/keg/N, or keg:~alias/N through the settings aliases", dest)) + } + case strings.HasPrefix(m[2], "~"): + if m[1] != "" { + problems = append(problems, fmt.Sprintf("link %q names both a namespace and an alias; write keg:~alias/N or keg:@namespace/keg/N", dest)) + } else if _, _, err := ResolveSettingsAlias(aliases, strings.TrimPrefix(m[2], "~")); err != nil { + problems = append(problems, fmt.Sprintf("link %q cannot be followed: %v", dest, err)) + } + } + return ast.WalkContinue, nil + }) + return problems +} diff --git a/pkg/keg/replace.go b/pkg/keg/replace.go new file mode 100644 index 00000000..b3fea048 --- /dev/null +++ b/pkg/keg/replace.go @@ -0,0 +1,254 @@ +package keg + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" +) + +// MaxReplaceNodes caps how many nodes one ReplaceText may change, so a +// careless pattern cannot rewrite a whole large keg in one call. +const MaxReplaceNodes = 500 + +// A short pattern and a long replacement multiply: a pattern that matches at +// every position, or at every word boundary, turns a small node and a small +// request into gigabytes. Each limit is checked before the replacement text +// is built, so an oversized replace allocates nothing for it. +const ( + // MaxReplaceNodeBytes caps the text a replace builds for one node: its + // body and metadata, where each matched. + MaxReplaceNodeBytes = 8 << 20 + // MaxReplaceTotalBytes caps every changed node's text after a replace, + // together. + MaxReplaceTotalBytes = 32 << 20 + // MaxReplaceNodeMatches caps the matches in one node's body or metadata. + MaxReplaceNodeMatches = 100_000 +) + +var errReplaceTooLarge = fmt.Errorf("the replacement would grow the notes past %d MiB in all; narrow it with a query or a more specific find: %w", MaxReplaceTotalBytes>>20, ErrInvalid) + +// ReplaceTextOptions is a keg-wide find and replace. +type ReplaceTextOptions struct { + // Find is literal text, or with Regex a Go regular expression. + Find string `json:"find"` + // Replace is the replacement; with Regex, $1 and ${name} expand groups. + Replace string `json:"replace"` + Regex bool `json:"regex,omitempty"` + // Query narrows the nodes considered, in node_list's query syntax. Empty + // considers every node. + Query string `json:"query,omitempty"` + // IncludeMeta replaces in each node's metadata YAML as well as its body. + IncludeMeta bool `json:"include_meta,omitempty"` + // DryRun reports what would change and writes nothing. + DryRun bool `json:"dry_run,omitempty"` +} + +// ReplacedNode is one node a replace changed, or would change. +type ReplacedNode struct { + ID NodeId `json:"id"` + Matches int `json:"matches"` + // ContentBefore/After and MetaBefore/After are set for the parts that + // change, on nodes that would be written. + ContentBefore string `json:"content_before,omitempty"` + ContentAfter string `json:"content_after,omitempty"` + MetaBefore string `json:"meta_before,omitempty"` + MetaAfter string `json:"meta_after,omitempty"` + // Error says why a node that matched is left alone: the write would be + // refused by any rule a node edit is held to (unparseable metadata, a + // body opening with frontmatter, the keg's schemas). A dry run reports + // the same errors a real run would. + Error string `json:"error,omitempty"` + // The written node's tokens; empty on a dry run or a skipped node. + Hash string `json:"hash,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` +} + +type ReplaceTextResult struct { + Nodes []ReplacedNode `json:"nodes"` +} + +// ReplaceText replaces text across the keg as one write: every node it +// changes is written, or none is. A node whose replacement would be refused +// is reported with an Error (and no bodies) and left as it was; the rest are +// written. MaxReplaceNodes counts every changed node, written or refused. +func (k *LocalKeg) ReplaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + if opts.DryRun { + return withKegReadValue(ctx, k, func(ctx context.Context) (ReplaceTextResult, error) { return k.replaceText(ctx, opts) }) + } + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (ReplaceTextResult, error) { return k.replaceText(ctx, opts) }) +} + +func (k *LocalKeg) replaceText(ctx context.Context, opts ReplaceTextOptions) (ReplaceTextResult, error) { + if opts.Find == "" { + return ReplaceTextResult{}, fmt.Errorf("find is required: %w", ErrInvalid) + } + // measure counts the matches in text and how long the text would be + // after the replace, without building it. ok is false past + // MaxReplaceNodeMatches. + measure := func(text string) (n, size int, ok bool) { + n = strings.Count(text, opts.Find) + return n, len(text) + n*(len(opts.Replace)-len(opts.Find)), n <= MaxReplaceNodeMatches + } + replace := func(text string) string { return strings.ReplaceAll(text, opts.Find, opts.Replace) } + if opts.Regex { + re, err := regexp.Compile(opts.Find) + if err != nil { + return ReplaceTextResult{}, fmt.Errorf("find is not a valid regular expression: %v: %w", err, ErrInvalid) + } + measure = func(text string) (n, size int, ok bool) { + matches := re.FindAllStringSubmatchIndex(text, MaxReplaceNodeMatches+1) + if len(matches) > MaxReplaceNodeMatches { + return len(matches), 0, false + } + size = len(text) + var expanded []byte + for _, m := range matches { + expanded = re.ExpandString(expanded[:0], opts.Replace, text, m) + // Past the node cap the exact size no longer matters. + if size += len(expanded) - (m[1] - m[0]); size > MaxReplaceNodeBytes { + break + } + } + return len(matches), size, true + } + replace = func(text string) string { return re.ReplaceAllString(text, opts.Replace) } + } + total := 0 + // replacePart replaces in one part of a node. The error is the node's, + // for a part too large to replace; the whole replace fails only when the + // total grows past MaxReplaceTotalBytes. + replacePart := func(text string, nodeSize *int) (string, int, string, error) { + n, size, ok := measure(text) + if n == 0 { + return text, 0, "", nil + } + if !ok { + return text, n, fmt.Sprintf("this note has more than %d matches; narrow the find", MaxReplaceNodeMatches), nil + } + if *nodeSize += size; *nodeSize > MaxReplaceNodeBytes { + return text, n, fmt.Sprintf("the replacement would grow this note past %d MiB", MaxReplaceNodeBytes>>20), nil + } + if total += size; total > MaxReplaceTotalBytes { + return text, n, "", errReplaceTooLarge + } + return replace(text), n, "", nil + } + + var ids []NodeId + if q := strings.TrimSpace(opts.Query); q != "" { + entries, err := k.query(ctx, QueryOptions{Expr: q}) + if err != nil { + return ReplaceTextResult{}, err + } + for _, entry := range entries { + if id, err := ParseNode(entry.ID); err == nil && id != nil { + ids = append(ids, *id) + } + } + } else { + listed, err := k.Repo.ListNodes(ctx) + if err != nil { + return ReplaceTextResult{}, err + } + ids = listed + } + + result := ReplaceTextResult{Nodes: []ReplacedNode{}} + var updates []NodeUpdateOptions + var written []int + for _, id := range ids { + view, err := k.ReadNode(ctx, id) + if err != nil { + continue + } + node := ReplacedNode{ID: id} + nodeSize := 0 + content, n, tooLarge, err := replacePart(string(view.Content), &nodeSize) + if err != nil { + return ReplaceTextResult{}, err + } + node.Matches += n + contentChanged := content != string(view.Content) + if contentChanged { + node.ContentBefore, node.ContentAfter = string(view.Content), content + } + metaChanged := false + if opts.IncludeMeta && tooLarge == "" { + meta, n, metaTooLarge, err := replacePart(string(view.Meta), &nodeSize) + if err != nil { + return ReplaceTextResult{}, err + } + node.Matches += n + tooLarge = metaTooLarge + if metaChanged = meta != string(view.Meta); metaChanged { + node.MetaBefore, node.MetaAfter = string(view.Meta), meta + } + } + if tooLarge != "" { + // Left alone like any refused node, and counted against the cap. + if len(result.Nodes) == MaxReplaceNodes { + return ReplaceTextResult{}, fmt.Errorf("this matches more than %d nodes; narrow it with a query: %w", MaxReplaceNodes, ErrInvalid) + } + result.Nodes = append(result.Nodes, ReplacedNode{ID: id, Matches: node.Matches, Error: tooLarge}) + continue + } + if !contentChanged && !metaChanged { + continue + } + update := NodeUpdateOptions{ID: id, ExpectedHash: view.Hash()} + if contentChanged { + update.Content, update.HasContent = []byte(node.ContentAfter), true + } + if metaChanged { + update.Meta, update.HasMeta = []byte(node.MetaAfter), true + } + // Hold each node to every rule a write would, dry run or not, so a + // node that would be refused is reported and left alone instead of + // failing the whole replace. + if len(result.Nodes) == MaxReplaceNodes { + return ReplaceTextResult{}, fmt.Errorf("this matches more than %d nodes; narrow it with a query: %w", MaxReplaceNodes, ErrInvalid) + } + if _, err := k.prepareNodeUpdate(ctx, update); err != nil { + // A refused node reports why, not its bodies: it is left alone. + result.Nodes = append(result.Nodes, ReplacedNode{ID: id, Matches: node.Matches, Error: replaceNodeError(err)}) + continue + } + result.Nodes = append(result.Nodes, node) + updates = append(updates, update) + written = append(written, len(result.Nodes)-1) + } + if opts.DryRun || len(updates) == 0 { + return result, nil + } + for start := 0; start < len(updates); start += MaxMutationBatchSize { + end := min(start+MaxMutationBatchSize, len(updates)) + results, err := k.updateNodes(ctx, updates[start:end]) + if err != nil { + // Report the failing node by its place in the result, not in + // the chunk. + var batchErr *BatchMutationError + if errors.As(err, &batchErr) && batchErr.Index >= 0 && start+batchErr.Index < len(written) { + batchErr.Index = written[start+batchErr.Index] + } + return ReplaceTextResult{}, err + } + for i, res := range results { + node := &result.Nodes[written[start+i]] + node.Hash, node.ContentHash, node.MetaHash = res.Hash, res.ContentHash, res.MetaHash + } + } + return result, nil +} + +// replaceNodeError is why a node a replace leaves alone would be refused, +// worded for the node's Error. +func replaceNodeError(err error) string { + var batchErr *BatchMutationError + if errors.As(err, &batchErr) && batchErr.Err != nil { + err = batchErr.Err + } + return strings.TrimSuffix(err.Error(), ": "+ErrInvalid.Error()) +} diff --git a/pkg/keg/repository_capabilities.go b/pkg/keg/repository_capabilities.go index 3a58d975..3594255c 100644 --- a/pkg/keg/repository_capabilities.go +++ b/pkg/keg/repository_capabilities.go @@ -18,6 +18,15 @@ func repoListImages(ctx context.Context, repo Repository, id NodeId) ([]string, return withImages.ListImages(ctx, id) } +// RepositoryNodeCounter is an optional repository capability: a counter +// that Next draws from, which an import raises past the ids it lands on +// before allocating any other id. +type RepositoryNodeCounter interface { + // RaiseNodeCounter makes Next hand out only ids greater than past. + // It never lowers the counter. + RaiseNodeCounter(ctx context.Context, past NodeId) error +} + func repoSnapshots(repo Repository) (RepositorySnapshots, bool) { withSnapshots, ok := repo.(RepositorySnapshots) if !ok { diff --git a/pkg/keg/trash.go b/pkg/keg/trash.go new file mode 100644 index 00000000..31f50a6a --- /dev/null +++ b/pkg/keg/trash.go @@ -0,0 +1,583 @@ +package keg + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + "time" +) + +// Why a node went to the trash. +const ( + // TrashReasonManual is a node someone removed. + TrashReasonManual = "manual" + // TrashReasonMerge is a node merged into another (TrashEntry.MergedInto). + TrashReasonMerge = "merge" + // TrashReasonArchiveRestore is a node an archive restore removed because + // the archive did not carry it. + TrashReasonArchiveRestore = "archive-restore" +) + +// MaxTrashBundleSize caps a trash bundle, a node with its history and files, +// unless the trash sets its own limit (RepositoryTrashLimit). Trashing a node +// whose bundle would pass the cap is refused with ErrTrashBundleTooLarge +// rather than failing opaquely in the repository. +const MaxTrashBundleSize = 256 << 20 + +// RepositoryTrashLimit is an optional RepositoryTrash capability: a trash +// that holds bundles up to its own limit, in bytes, in place of +// MaxTrashBundleSize. A limit of zero or less keeps the default. +type RepositoryTrashLimit interface { + // TrashBundleLimit returns the largest bundle, in bytes, the trash holds. + TrashBundleLimit() int +} + +func trashBundleLimit(trash RepositoryTrash) int { + if limited, ok := trash.(RepositoryTrashLimit); ok && limited.TrashBundleLimit() > 0 { + return limited.TrashBundleLimit() + } + return MaxTrashBundleSize +} + +// formatTrashLimit names a bundle limit for a person. +func formatTrashLimit(limit int) string { + if limit >= 1<<20 && limit%(1<<20) == 0 { + return fmt.Sprintf("%d MiB", limit>>20) + } + return fmt.Sprintf("%d bytes", limit) +} + +// ErrTrashBundleTooLarge refuses to trash a node too large for the trash. It +// wraps ErrInvalid. +var ErrTrashBundleTooLarge = fmt.Errorf("too large for the trash: %w", ErrInvalid) + +// cappedBuffer is a bytes.Buffer that refuses to grow past max, so building +// an oversized bundle stops at the cap instead of holding all of it. +type cappedBuffer struct { + bytes.Buffer + max int +} + +func (b *cappedBuffer) Write(p []byte) (int, error) { + if b.Len()+len(p) > b.max { + return 0, ErrTrashBundleTooLarge + } + return b.Buffer.Write(p) +} + +// TrashEntry describes a removed node a repository keeps for restoring. +type TrashEntry struct { + ID NodeId `json:"id"` + Title string `json:"title"` + Reason string `json:"reason"` + // MergedInto is the node a merge folded this one into. + MergedInto *NodeId `json:"merged_into,omitempty"` + TrashedAt time.Time `json:"trashed_at"` + // ExpiresAt is when the repository will purge the entry, nil for never. + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Size is the stored bundle's size in bytes. + Size int64 `json:"size"` + // DisplacedFrom is the id the entry was trashed at, when an archive + // restore needed that id and the entry moved to ID to make way. Its + // bundle still names the old id; restoring puts it back at ID. + DisplacedFrom *NodeId `json:"displaced_from,omitempty"` +} + +// TrashWrite is what a repository stores for a node it trashes. Bundle is a +// keg archive of the node with its history and assets, which restoring +// imports back at the node's id. +type TrashWrite struct { + Title string + Reason string + MergedInto *NodeId + Bundle []byte +} + +// RepositoryTrash is an optional repository capability: removed nodes go to +// a trash they can be restored from rather than being deleted outright. +type RepositoryTrash interface { + // TrashNode stores in and deletes the node, as one write. It refuses + // with ErrDestinationExists when id already has a trash entry, and never + // overwrites one. + TrashNode(ctx context.Context, id NodeId, in TrashWrite) error + // ListTrash returns the trashed nodes, newest first. + ListTrash(ctx context.Context) ([]TrashEntry, error) + // ReadTrash returns one entry and its bundle; ErrNotExist when absent. + ReadTrash(ctx context.Context, id NodeId) (TrashEntry, []byte, error) + // TrashInfo returns one entry without its bundle; ErrNotExist when + // absent. It is the cheap check for whether an id is trashed. + TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) + // MoveTrash re-keys the entry at from to the free id to, recording from + // as its DisplacedFrom. It refuses with ErrDestinationExists when to + // already has an entry, and ErrNotExist when from has none. + MoveTrash(ctx context.Context, from, to NodeId) error + // DeleteTrash drops an entry; absent entries are not an error. + DeleteTrash(ctx context.Context, id NodeId) error +} + +func repoTrash(repo Repository) (RepositoryTrash, bool) { + trash, ok := repo.(RepositoryTrash) + return trash, ok +} + +// idTrashed reports whether id has an entry in the keg's trash. A trashed +// node's id is reserved: nothing but a restore may put a node there. +func (k *LocalKeg) idTrashed(ctx context.Context, id NodeId) (bool, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return false, nil + } + _, err := trash.TrashInfo(ctx, id) + switch { + case err == nil: + return true, nil + case errors.Is(err, ErrNotExist): + return false, nil + default: + return false, err + } +} + +// displaceTrashed makes way for an import landing on id: a trash entry +// there moves to a fresh id from the repository's counter, never one in +// avoid (the ids the import lands on), so the import can take id and the +// trashed node stays restorable at its new one. It reports the new id, or +// ok false when id held no entry. +func (k *LocalKeg) displaceTrashed(ctx context.Context, id NodeId, avoid map[NodeId]bool) (NodeId, bool, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return NodeId{}, false, nil + } + isTrashed, err := k.idTrashed(ctx, id) + if err != nil || !isTrashed { + return NodeId{}, false, err + } + var to NodeId + for { + next, err := k.Repo.Next(ctx) + if err != nil { + return NodeId{}, false, fmt.Errorf("unable to allocate an id for trashed node %s: %w", id.Path(), err) + } + // Next reserves the id as an empty node; the trash entry takes it, + // not a node. + k.cleanupUnusedImportReservations(ctx, []NodeId{next}) + // The import raised the counter past its own ids first; this is the + // second guard against handing the entry one of them. + if !avoid[next] { + to = next + break + } + } + if err := trash.MoveTrash(ctx, id, to); err != nil { + return NodeId{}, false, fmt.Errorf("unable to move trashed node %s out of the way: %w", id.Path(), err) + } + return to, true, nil +} + +// trashInfo says why removeUnchecked is removing a node. +type trashInfo struct { + reason string + mergedInto *NodeId +} + +var manualRemoval = trashInfo{reason: TrashReasonManual} + +// trashNode bundles id and hands it to the repository's trash, which deletes +// the live node. Inbound links are left pointing at the trashed id so a +// restore is lossless; purging rewrites them. A node whose bundle would pass +// MaxTrashBundleSize is refused with ErrTrashBundleTooLarge and left alone. +func (k *LocalKeg) trashNode(ctx context.Context, trash RepositoryTrash, id NodeId, info trashInfo) error { + view, err := k.ReadNode(ctx, id) + if err != nil { + return err + } + snapshots, withHistory := repoSnapshots(k.Repo) + opts := ExportNodesOptions{NodeIDs: []NodeId{id}, WithHistory: withHistory, HistoryIfSupported: true, WithAssets: true} + limit := trashBundleLimit(trash) + bundle := cappedBuffer{max: limit} + if err := k.writeArchive(ctx, &bundle, []NodeId{id}, snapshots, opts); err != nil { + if errors.Is(err, ErrTrashBundleTooLarge) { + return fmt.Errorf("node %s with its history and files passes the trash's %s limit, so it cannot go to the trash; remove large files or snapshots first: %w", + id.Path(), formatTrashLimit(limit), ErrTrashBundleTooLarge) + } + return fmt.Errorf("failed to bundle node %s for the trash: %w", id.Path(), err) + } + return k.withNodeLock(ctx, id, func(lockCtx context.Context) error { + return trash.TrashNode(lockCtx, id, TrashWrite{ + Title: ExplicitMarkdownTitle(view.Content), + Reason: info.reason, + MergedInto: info.mergedInto, + Bundle: bundle.Bytes(), + }) + }) +} + +// TrashNodesResult says what TrashNodes did. +type TrashNodesResult struct { + // Trashed is the nodes now in the trash. + Trashed []NodeId + // TooLarge is the nodes left in place because their bundle would pass + // MaxTrashBundleSize. + TooLarge []NodeId +} + +// TrashNodes moves nodes to the trash for reason, one at a time, leaving +// links to them alone, as a removal does, and updates the dex once at the +// end. Ids with no live node are skipped, node 0 is refused, and a node too +// large for the trash stays where it is and is reported in TooLarge. It is +// for bulk removals with a reason of their own, such as an archive restore +// dropping what the archive did not carry; a merge goes through MergeNodes. +// ErrNotSupported when the repository keeps no trash. +func (k *LocalKeg) TrashNodes(ctx context.Context, ids []NodeId, reason string) (TrashNodesResult, error) { + return withKegWriteValue(ctx, k, func(ctx context.Context) (TrashNodesResult, error) { + return k.trashNodes(ctx, ids, reason) + }) +} + +func (k *LocalKeg) trashNodes(ctx context.Context, ids []NodeId, reason string) (TrashNodesResult, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return TrashNodesResult{}, ErrNotSupported + } + if reason == "" || reason == TrashReasonMerge { + return TrashNodesResult{}, fmt.Errorf("trash reason %q: %w", reason, ErrInvalid) + } + result := TrashNodesResult{Trashed: []NodeId{}, TooLarge: []NodeId{}} + var errs []error + for _, id := range ids { + if id.ID == 0 { + return result, fmt.Errorf("node 0 cannot be removed: %w", ErrInvalid) + } + live, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + errs = append(errs, err) + break + } + if !live { + continue + } + err = k.trashNode(ctx, trash, id, trashInfo{reason: reason}) + if errors.Is(err, ErrTrashBundleTooLarge) { + result.TooLarge = append(result.TooLarge, id) + continue + } + if err != nil { + errs = append(errs, err) + break + } + result.Trashed = append(result.Trashed, id) + } + if len(result.Trashed) > 0 { + errs = append(errs, k.afterRemoveMany(ctx, result.Trashed)) + } + return result, errors.Join(errs...) +} + +// afterRemoveMany drops removed nodes from the dex in one write and stamps +// the keg. +func (k *LocalKeg) afterRemoveMany(ctx context.Context, ids []NodeId) error { + var errs []error + dex, err := k.ensureDexFresh(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("failed to retrieve dex after remove: %w", err)) + } else { + for _, id := range ids { + if err := dex.Remove(ctx, id); err != nil { + errs = append(errs, fmt.Errorf("failed to remove %s from dex: %w", id.Path(), err)) + } + } + if err := dex.Write(ctx, k.Repo); err != nil { + errs = append(errs, fmt.Errorf("failed to write dex after remove: %w", err)) + } else { + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + } + } + if err := k.touchSettingsUpdated(ctx, k.Runtime.Clock().Now()); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after remove: %w", err)) + } + return errors.Join(errs...) +} + +// ListTrash returns the keg's trashed nodes, newest first, or ErrNotSupported +// when its repository keeps no trash. +func (k *LocalKeg) ListTrash(ctx context.Context) ([]TrashEntry, error) { + return withKegReadValue(ctx, k, func(ctx context.Context) ([]TrashEntry, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return nil, ErrNotSupported + } + return trash.ListTrash(ctx) + }) +} + +// TrashInfo returns id's trash entry without its bundle: ErrNotExist when id +// is not trashed, ErrNotSupported when the repository keeps no trash. +func (k *LocalKeg) TrashInfo(ctx context.Context, id NodeId) (TrashEntry, error) { + return withKegReadValue(ctx, k, func(ctx context.Context) (TrashEntry, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return TrashEntry{}, ErrNotSupported + } + return trash.TrashInfo(ctx, id) + }) +} + +// RestoreTrashed puts trashed nodes back at their own ids, with the history +// and assets they had. Links to them were left in place when they were +// trashed, so they resolve again. All or nothing. +func (k *LocalKeg) RestoreTrashed(ctx context.Context, ids []NodeId) error { + _, err := withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (struct{}, error) { + return struct{}{}, k.restoreTrashed(ctx, ids) + }) + return err +} + +func (k *LocalKeg) restoreTrashed(ctx context.Context, ids []NodeId) error { + trash, ok := repoTrash(k.Repo) + if !ok { + return ErrNotSupported + } + if err := validateMutationBatchSize(len(ids)); err != nil { + return err + } + // The node is put back as it was; the keg's schemas may have moved on + // since, and a restore must not be refused for it. + ctx = WithValidationMode(ctx, ValidationModeOff) + seen := make(map[NodeId]bool, len(ids)) + for _, id := range ids { + if seen[id] { + return fmt.Errorf("node %s is listed twice: %w", id.Path(), ErrInvalid) + } + seen[id] = true + } + for _, id := range ids { + _, bundle, err := trash.ReadTrash(ctx, id) + if err != nil { + return fmt.Errorf("trashed node %s: %w", id.Path(), err) + } + exists, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + return err + } + if exists { + return fmt.Errorf("node %s exists, so its trashed copy cannot be restored over it: %w", id.Path(), ErrDestinationExists) + } + // The bundle names the id the node was trashed at, which an archive + // restore may since have given to another node; the node comes back + // at the entry's current id, its links to itself with it. Other + // nodes' links to the old id belong to whatever holds it. The import + // leaves the dex alone: it is updated once, below, for the batch. + to := id + opts := ImportNodesOptions{HistoryIfSupported: true, restoreFromTrash: true, restoreTo: &to} + if _, err := k.importNodes(ctx, bytes.NewReader(bundle), opts); err != nil { + return fmt.Errorf("failed to restore node %s: %w", id.Path(), err) + } + if err := trash.DeleteTrash(ctx, id); err != nil { + return err + } + } + return k.indexRestored(ctx, ids) +} + +// indexRestored adds restored nodes to the dex without rebuilding it from +// every node: each restored node is added, and the links other nodes hold to +// it, which the removal dropped from the link indexes, are found in one +// batched read of the keg's stats. A repository without batched reads gets +// one full rebuild for the whole restore. +func (k *LocalKeg) indexRestored(ctx context.Context, ids []NodeId) error { + now := k.Runtime.Clock().Now() + batch, ok := repositoryBatchRead(k.Repo) + if !ok { + if err := k.rebuildDexFromRepo(ctx); err != nil { + return err + } + return k.touchSettingsUpdated(ctx, now) + } + restored := make(map[string]bool, len(ids)) + nodes := make([]*NodeData, 0, len(ids)) + for _, id := range ids { + data, err := k.loadNodeDataForDex(ctx, id) + if err != nil { + return fmt.Errorf("unable to read restored node %s for the dex: %w", id.Path(), err) + } + restored[id.Path()] = true + nodes = append(nodes, data) + } + all, err := k.Repo.ListNodes(ctx) + if err != nil { + return fmt.Errorf("unable to list nodes after restore: %w", err) + } + stats, err := batch.ReadStatsBatch(ctx, all) + if err != nil { + return fmt.Errorf("unable to read node links after restore: %w", err) + } + type inbound struct{ src, dst NodeId } + var links []inbound + for _, src := range all { + if restored[src.Path()] { + continue + } + for _, dst := range stats[src.Path()].Links() { + if restored[dst.Path()] { + links = append(links, inbound{src: src, dst: dst}) + } + } + } + dex, err := k.ensureDexFresh(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve dex after restore: %w", err) + } + for _, data := range nodes { + if err := dex.Add(ctx, data); err != nil { + return fmt.Errorf("failed to add restored node %s to dex: %w", data.ID.Path(), err) + } + } + dex.mu.Lock() + for _, link := range links { + addDexLink(dex, link.src, link.dst) + } + dex.mu.Unlock() + if err := dex.Write(ctx, k.Repo); err != nil { + return fmt.Errorf("failed to write dex after restore: %w", err) + } + k.dexMu.Lock() + k.recordDexWrite() + k.dexMu.Unlock() + return k.touchSettingsUpdated(ctx, now) +} + +// addDexLink records src linking to dst in the dex's link and backlink +// indexes. The caller holds dex.mu. +func addDexLink(dex *Dex, src, dst NodeId) { + if dex.links.data == nil { + dex.links.data = map[string][]NodeId{} + } + if !slices.ContainsFunc(dex.links.data[src.Path()], dst.Equals) { + dex.links.data[src.Path()] = append(dex.links.data[src.Path()], dst) + } + if dex.backlinks.data == nil { + dex.backlinks.data = map[string][]NodeId{} + } + if !slices.ContainsFunc(dex.backlinks.data[dst.Path()], src.Equals) { + dex.backlinks.data[dst.Path()] = append(dex.backlinks.data[dst.Path()], src) + } +} + +// mapPurgedLinks points a trash bundle's links to nodes that are gone for +// good -- purged since the node was trashed, neither live nor in the trash -- +// at node 0, as purging did to the links live nodes held. Links to a live +// node or to another trashed node are left alone. +func (k *LocalKeg) mapPurgedLinks(ctx context.Context, entries map[string][]byte, ordered []string, mapping map[string]NodeId) error { + for _, sourceID := range ordered { + base := "keg-archive/nodes/" + sourceID + var targets []NodeId + if raw, ok := entries[base+"/README.md"]; ok { + if content, err := ParseContent(k.Runtime, raw, MarkdownContentFilename); err == nil { + targets = append(targets, content.Links...) + } + } + if raw, ok := entries[base+"/stats.json"]; ok { + if stats, err := ParseStats(ctx, raw); err == nil { + targets = append(targets, stats.Links()...) + } + } + for _, target := range targets { + if _, mapped := mapping[target.Path()]; mapped || target.ID == 0 || target.Code != "" || target.Alias != "" { + continue + } + live, err := k.nodeExistsWithContent(ctx, target) + if err != nil { + return err + } + trashed, err := k.idTrashed(ctx, target) + if err != nil { + return err + } + if !live && !trashed { + mapping[target.Path()] = NodeId{ID: 0} + } + } + } + return nil +} + +// PurgeTrashOptions names the trashed nodes to delete for good. +type PurgeTrashOptions struct { + IDs []NodeId `json:"nodes"` + // ExpiredAt, when set, purges only entries whose ExpiresAt is set and + // not after it. The expiry is checked inside the write, so an entry + // restored and trashed again since it was listed is left alone. + ExpiredAt *time.Time `json:"-"` +} + +// PurgeTrashResult says what a purge did. +type PurgeTrashResult struct { + // Purged is the ids whose trash entries were deleted. Ids with no entry, + // or not yet expired under ExpiredAt, are absent. + Purged []NodeId `json:"purged"` + // Rewritten is the nodes whose links to a purged id now point at node 0. + Rewritten []NodeId `json:"rewritten"` +} + +// PurgeTrashed deletes 1-100 trashed nodes for good, as one write, and +// points links that still name them at the zero node, as removing a node +// without a trash does. Links are left alone for an id a live node holds. +func (k *LocalKeg) PurgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) (PurgeTrashResult, error) { return k.purgeTrashed(ctx, opts) }) +} + +func (k *LocalKeg) purgeTrashed(ctx context.Context, opts PurgeTrashOptions) (PurgeTrashResult, error) { + trash, ok := repoTrash(k.Repo) + if !ok { + return PurgeTrashResult{}, ErrNotSupported + } + if err := validateMutationBatchSize(len(opts.IDs)); err != nil { + return PurgeTrashResult{}, err + } + seen := make(map[NodeId]bool, len(opts.IDs)) + for _, id := range opts.IDs { + if seen[id] { + return PurgeTrashResult{}, fmt.Errorf("node %s is listed twice: %w", id.Path(), ErrInvalid) + } + seen[id] = true + } + result := PurgeTrashResult{Purged: []NodeId{}, Rewritten: []NodeId{}} + var orphaned []NodeId + for _, id := range opts.IDs { + entry, err := trash.TrashInfo(ctx, id) + if errors.Is(err, ErrNotExist) { + continue + } + if err != nil { + return PurgeTrashResult{}, err + } + if opts.ExpiredAt != nil && (entry.ExpiresAt == nil || entry.ExpiresAt.After(*opts.ExpiredAt)) { + continue + } + if err := trash.DeleteTrash(ctx, id); err != nil { + return PurgeTrashResult{}, err + } + result.Purged = append(result.Purged, id) + // Links to an id a live node holds are that node's links. + live, err := k.nodeExistsWithContent(ctx, id) + if err != nil { + return PurgeTrashResult{}, err + } + if !live { + orphaned = append(orphaned, id) + } + } + if len(orphaned) == 0 { + return result, nil + } + rewritten, err := k.retargetLinksMany(ctx, orphaned, NodeId{ID: 0}) + if err != nil { + return PurgeTrashResult{}, err + } + result.Rewritten = append(result.Rewritten, rewritten...) + return result, nil +} diff --git a/pkg/keg/trash_test.go b/pkg/keg/trash_test.go new file mode 100644 index 00000000..56d10f9d --- /dev/null +++ b/pkg/keg/trash_test.go @@ -0,0 +1,1142 @@ +package keg_test + +import ( + "bytes" + "context" + "crypto/rand" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/jlrickert/tapper/internal/testkegrepo" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +// trashingRepo is the memory repository with a trash, as the hub keeps one. +type trashingRepo struct { + *testkegrepo.MemoryRepository + mu sync.Mutex + trash map[keg.NodeId]trashedNode + // floor is the highest id Next may not hand out, as the hub's + // next_node_id counter only rises. + floor int +} + +func (r *trashingRepo) RaiseNodeCounter(_ context.Context, past keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + r.floor = max(r.floor, past.ID) + return nil +} + +type trashedNode struct { + entry keg.TrashEntry + bundle []byte +} + +// Next never hands out a trashed id, as the hub's rising counter never does. +func (r *trashingRepo) Next(ctx context.Context) (keg.NodeId, error) { + for { + id, err := r.MemoryRepository.Next(ctx) + if err != nil { + return id, err + } + r.mu.Lock() + _, trashed := r.trash[id] + below := id.ID <= r.floor + r.mu.Unlock() + if !trashed && !below { + return id, nil + } + } +} + +func (r *trashingRepo) TrashNode(ctx context.Context, id keg.NodeId, in keg.TrashWrite) error { + r.mu.Lock() + _, taken := r.trash[id] + r.mu.Unlock() + if taken { + return keg.ErrDestinationExists + } + if err := r.DeleteNode(ctx, id); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.trash[id] = trashedNode{entry: keg.TrashEntry{ID: id, Title: in.Title, Reason: in.Reason, MergedInto: in.MergedInto, Size: int64(len(in.Bundle))}, bundle: in.Bundle} + return nil +} + +func (r *trashingRepo) ListTrash(context.Context) ([]keg.TrashEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]keg.TrashEntry, 0, len(r.trash)) + for _, item := range r.trash { + out = append(out, item.entry) + } + return out, nil +} + +func (r *trashingRepo) ReadTrash(_ context.Context, id keg.NodeId) (keg.TrashEntry, []byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[id] + if !ok { + return keg.TrashEntry{}, nil, keg.ErrNotExist + } + return item.entry, item.bundle, nil +} + +// expire sets when the trash would purge id's entry. +func (r *trashingRepo) expire(id keg.NodeId, at time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + item := r.trash[id] + item.entry.ExpiresAt = &at + r.trash[id] = item +} + +// plant puts an entry in the trash directly, as a stale or legacy entry is. +func (r *trashingRepo) plant(id keg.NodeId) { + r.mu.Lock() + defer r.mu.Unlock() + r.trash[id] = trashedNode{entry: keg.TrashEntry{ID: id, Reason: keg.TrashReasonManual}} +} + +func (r *trashingRepo) TrashInfo(_ context.Context, id keg.NodeId) (keg.TrashEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[id] + if !ok { + return keg.TrashEntry{}, keg.ErrNotExist + } + return item.entry, nil +} + +func (r *trashingRepo) MoveTrash(_ context.Context, from, to keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + item, ok := r.trash[from] + if !ok { + return keg.ErrNotExist + } + if _, taken := r.trash[to]; taken { + return keg.ErrDestinationExists + } + delete(r.trash, from) + // The original id is kept through later displacements. + if item.entry.DisplacedFrom == nil { + old := from + item.entry.DisplacedFrom = &old + } + item.entry.ID = to + r.trash[to] = item + return nil +} + +func (r *trashingRepo) DeleteTrash(_ context.Context, id keg.NodeId) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.trash, id) + return nil +} + +func TestTrashKeepsRemovedNodesRestorable(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n\nKeep me.\n"), Meta: []byte("tags: [kept]\n")}) + require.NoError(t, err) + _, err = k.AppendSnapshot(ctx, gone.ID, "before removal") + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\nSee [gone](../%d).\n", gone.ID.ID))}) + require.NoError(t, err) + before, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: before.Hash()}) + require.NoError(t, err) + exists, err := k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.False(t, exists) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, gone.ID, trash[0].ID) + require.Equal(t, "Gone", trash[0].Title) + require.Equal(t, keg.TrashReasonManual, trash[0].Reason) + info, err := k.TrashInfo(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, "Gone", info.Title) + _, err = k.TrashInfo(ctx, linker.ID) + require.ErrorIs(t, err, keg.ErrNotExist, "a live node is not trashed") + // The link is left alone so a restore is lossless. + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), fmt.Sprintf("../%d)", gone.ID.ID)) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID})) + restored, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, before.Content, restored.Content) + require.Equal(t, before.Meta, restored.Meta) + snapshots, err := k.ListSnapshots(ctx, gone.ID) + require.NoError(t, err) + require.NotEmpty(t, snapshots) + trash, err = k.ListTrash(ctx) + require.NoError(t, err) + require.Empty(t, trash) + _, err = k.TrashInfo(ctx, gone.ID) + require.ErrorIs(t, err, keg.ErrNotExist, "a restored node is no longer trashed") + require.ErrorIs(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}), keg.ErrNotExist) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: restored.Hash()}) + require.NoError(t, err) + purged, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{gone.ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{gone.ID}, purged.Purged) + require.Equal(t, []keg.NodeId{linker.ID}, purged.Rewritten) + linking, err = k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), "../0)") + require.ErrorIs(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}), keg.ErrNotExist) +} + +func TestRemoveWithoutTrashRewritesLinks(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[gone](../%d)\n", gone.ID.ID))}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: gone.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), "../0)") + _, err = k.ListTrash(ctx) + require.ErrorIs(t, err, keg.ErrNotSupported) + _, err = k.TrashInfo(ctx, gone.ID) + require.ErrorIs(t, err, keg.ErrNotSupported) +} + +func TestMergeNodesFoldsADuplicateIntoTheTarget(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + target, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Topic\n\nFirst take.\n")}) + require.NoError(t, err) + dup, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Topic again\n\nSecond take.\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[one](../%d) and [two](../%d)\n", target.ID.ID, dup.ID.ID))}) + require.NoError(t, err) + targetView, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + dupView, err := k.ReadNode(ctx, dup.ID) + require.NoError(t, err) + + merged := fmt.Sprintf("# Topic\n\nFirst take.\n\nSecond take, once [a duplicate](../%d).\n", dup.ID.ID) + _, err = k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: dup.ID, ExpectedSourceHash: "stale", + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte(merged), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + var conflict *keg.PreconditionConflictError + require.ErrorAs(t, err, &conflict, "a stale source hash changes nothing") + unchanged, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + require.Equal(t, targetView.Content, unchanged.Content) + + result, err := k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: dup.ID, ExpectedSourceHash: dupView.Hash(), + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte(merged), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{linker.ID}, result.Rewritten) + + after, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Topic\n\nFirst take.\n\nSecond take, once [a duplicate](../%d).\n", target.ID.ID), string(after.Content), "the target's own link to the source points at itself") + require.Equal(t, after.ContentHash(), result.Target.ContentHash) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Linker\n\n[one](../%d) and [two](../%d)\n", target.ID.ID, target.ID.ID), string(linking.Content)) + + exists, err := k.NodeExists(ctx, dup.ID) + require.NoError(t, err) + require.False(t, exists) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, keg.TrashReasonMerge, trash[0].Reason) + require.NotNil(t, trash[0].MergedInto) + require.Equal(t, target.ID, *trash[0].MergedInto) + + // Restoring the source brings it back; the links stay with the target. + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{dup.ID})) + restored, err := k.ReadNode(ctx, dup.ID) + require.NoError(t, err) + require.Contains(t, string(restored.Content), "Second take.") + linking, err = k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.NotContains(t, string(linking.Content), fmt.Sprintf("../%d)", dup.ID.ID)) +} + +func TestMergeNodesRefusesWhatIsNotAMerge(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + for name, opts := range map[string]keg.MergeNodesOptions{ + "into itself": {Source: a.ID, ExpectedSourceHash: view.Hash(), Target: keg.NodeUpdateOptions{ID: a.ID, Content: []byte("# A\n"), HasContent: true, ExpectedContentHash: view.ContentHash()}}, + "node 0": {Source: keg.NodeId{ID: 0}, Target: keg.NodeUpdateOptions{ID: a.ID, Content: []byte("# A\n"), HasContent: true}}, + "no body": {Source: keg.NodeId{ID: 0}, Target: keg.NodeUpdateOptions{ID: a.ID, Meta: []byte("tags: [x]\n"), HasMeta: true}}, + } { + _, err := k.MergeNodes(ctx, opts) + require.ErrorIs(t, err, keg.ErrInvalid, name) + } +} + +func TestReplaceTextAcrossTheKeg(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Alpha\n\nUses Widget v1 and Widget v1.\n"), Meta: []byte("tags: [widget]\n")}) + require.NoError(t, err) + b, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Beta\n\nWidget v1 here too.\n"), Meta: []byte("tags: [other]\n")}) + require.NoError(t, err) + c, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gamma\n\nNothing to see.\n")}) + require.NoError(t, err) + read := func(id keg.NodeId) *keg.NodeView { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + return view + } + before := read(c.ID).Hash() + + dry, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "Widget v1", Replace: "Widget v2", DryRun: true}) + require.NoError(t, err) + require.Len(t, dry.Nodes, 2) + require.Equal(t, 3, dry.Nodes[0].Matches+dry.Nodes[1].Matches, "three matches over two nodes") + require.Contains(t, string(read(a.ID).Content), "Widget v1", "a dry run writes nothing") + + done, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: `Widget v(\d)`, Replace: "Gadget v$1", Regex: true, Query: "widget"}) + require.NoError(t, err) + require.Len(t, done.Nodes, 1, "the query keeps it to the tagged node") + require.Equal(t, "# Alpha\n\nUses Gadget v1 and Gadget v1.\n", string(read(a.ID).Content)) + require.Equal(t, read(a.ID).ContentHash(), done.Nodes[0].ContentHash) + require.Contains(t, string(read(b.ID).Content), "Widget v1") + require.Equal(t, before, read(c.ID).Hash()) + + broken, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "tags: [other]", Replace: "tags: [other", IncludeMeta: true}) + require.NoError(t, err) + require.Len(t, broken.Nodes, 1) + require.NotEmpty(t, broken.Nodes[0].Error, "metadata that would not parse is reported") + require.Contains(t, string(read(b.ID).Meta), "other]", "and left alone") + + meta, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "widget", Replace: "gadget", IncludeMeta: true}) + require.NoError(t, err) + require.Len(t, meta.Nodes, 1) + require.Contains(t, string(read(a.ID).Meta), "gadget") + + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "(", Regex: true}) + require.ErrorIs(t, err, keg.ErrInvalid) + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{}) + require.ErrorIs(t, err, keg.ErrInvalid) +} + +func newTrashingKeg(t *testing.T) (*keg.LocalKeg, *trashingRepo, context.Context) { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + return k, repo, ctx +} + +func TestTrashedIDIsReserved(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n\nOriginal.\n")}) + require.NoError(t, err) + b, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# B\n")}) + require.NoError(t, err) + aView, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: aView.Hash()}) + require.NoError(t, err) + _, bundle, err := k.Repo.(keg.RepositoryTrash).ReadTrash(ctx, a.ID) + require.NoError(t, err) + + bView, err := k.ReadNode(ctx, b.ID) + require.NoError(t, err) + _, err = k.Move(ctx, keg.NodeMoveOptions{Source: b.ID, Destination: a.ID, ExpectedHash: bView.Hash()}) + require.ErrorIs(t, err, keg.ErrDestinationExists, "a trashed id is not free to move onto") + stillB, err := k.ReadNode(ctx, b.ID) + require.NoError(t, err) + require.Equal(t, bView.Content, stillB.Content) + + _, after, err := k.Repo.(keg.RepositoryTrash).ReadTrash(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, bundle, after, "the trashed copy is untouched") + exists, err := k.NodeExists(ctx, a.ID) + require.NoError(t, err) + require.False(t, exists) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{a.ID})) + restored, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, aView.Content, restored.Content) +} + +// An archive always restores: a trashed node holding an archived id moves to +// a fresh id, keeps its content, and restores there with its links to itself +// following it, while links elsewhere stay with the archived node. +func TestArchiveRestoreDisplacesATrashedNode(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + n := a.ID.ID + archivedBody := fmt.Sprintf("# A\n\nArchived, see [me](../%d).\n", n) + require.NoError(t, k.Repo.WriteContent(ctx, a.ID, []byte(archivedBody))) + linkerBody := fmt.Sprintf("# Linker\n\n[a](../%d)\n", n) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{a.ID}}) + require.NoError(t, err) + archived, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + + trashedBody := fmt.Sprintf("# A\n\nLater, see [me](../%d).\n", n) + require.NoError(t, k.Repo.WriteContent(ctx, a.ID, []byte(trashedBody))) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archived), keg.ImportNodesOptions{}) + require.NoError(t, err, "an archive restores even when its id is in the trash") + got, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, archivedBody, string(got.Content)) + + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + moved := trash[0].ID + require.NotEqual(t, a.ID, moved) + require.NotNil(t, trash[0].DisplacedFrom) + require.Equal(t, a.ID, *trash[0].DisplacedFrom) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{moved})) + back, err := k.ReadNode(ctx, moved) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# A\n\nLater, see [me](../%d).\n", moved.ID), string(back.Content), "the trashed content, its self-link following it") + still, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, archivedBody, string(still.Content), "the archived node keeps its id") + link, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(link.Content), "links elsewhere stay with the archived node") + + // The archived node can itself be trashed as usual. + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: still.Hash()}) + require.NoError(t, err) + trash, err = k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + require.Equal(t, a.ID, trash[0].ID) + require.Nil(t, trash[0].DisplacedFrom) +} + +// Purging a displaced entry rewrites links to its new id, never its old one, +// which belongs to the archived node. +func TestPurgingADisplacedEntryLeavesTheOldIDsLinks(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{a.ID}}) + require.NoError(t, err) + archived, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + linkerBody := fmt.Sprintf("# Linker\n\n[a](../%d)\n", a.ID.ID) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + _, err = k.ImportNodes(ctx, bytes.NewReader(archived), keg.ImportNodesOptions{}) + require.NoError(t, err) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{trash[0].ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{trash[0].ID}, result.Purged) + require.Empty(t, result.Rewritten) + got, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(got.Content)) +} + +func TestRestoreTrashedRefusesDuplicateIDs(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + a, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# A\n")}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, a.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: a.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + err = k.RestoreTrashed(ctx, []keg.NodeId{a.ID, a.ID}) + require.ErrorIs(t, err, keg.ErrInvalid) + require.ErrorContains(t, err, "listed twice") + exists, err := k.NodeExists(ctx, a.ID) + require.NoError(t, err) + require.False(t, exists, "nothing was restored") + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) +} + +func TestMergedSourceRestoresAsItWas(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + target, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Target\n")}) + require.NoError(t, err) + source, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Source\n")}) + require.NoError(t, err) + selfLinking := fmt.Sprintf("# Source\n\nSee [myself](../%d).\n", source.ID.ID) + require.NoError(t, k.Repo.WriteContent(ctx, source.ID, []byte(selfLinking))) + third, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Third\n\n[src](../%d)\n", source.ID.ID))}) + require.NoError(t, err) + sourceView, err := k.ReadNode(ctx, source.ID) + require.NoError(t, err) + targetView, err := k.ReadNode(ctx, target.ID) + require.NoError(t, err) + + _, err = k.MergeNodes(ctx, keg.MergeNodesOptions{ + Source: source.ID, ExpectedSourceHash: sourceView.Hash(), + Target: keg.NodeUpdateOptions{ID: target.ID, Content: []byte("# Target\n\nMerged.\n"), HasContent: true, ExpectedContentHash: targetView.ContentHash()}, + }) + require.NoError(t, err) + thirdView, err := k.ReadNode(ctx, third.ID) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("# Third\n\n[src](../%d)\n", target.ID.ID), string(thirdView.Content)) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{source.ID})) + restored, err := k.ReadNode(ctx, source.ID) + require.NoError(t, err) + require.Equal(t, selfLinking, string(restored.Content)) +} + +func TestRetargetLinks(t *testing.T) { + t.Parallel() + got := keg.RetargetLinks([]byte("[a](../3) [b](../30) [c](../3#x)\n"), keg.NodeId{ID: 3}, keg.NodeId{ID: 7}) + require.Equal(t, "[a](../7) [b](../30) [c](../7#x)\n", string(got)) +} + +func TestReplaceTextLeavesRefusedNodesAloneAndDryRunPredictsThem(t *testing.T) { + t.Parallel() + k, ctx := newStrictBatchKeg(t) + created, err := k.CreateNodes(ctx, []keg.NodeCreate{ + {Key: "typed", Schema: "task", Body: []byte("# Typed\n\nfoo here\n"), Meta: []byte("type: task\n")}, + {Key: "untyped", Schema: "task", Body: []byte("# Untyped\n\nfoo there\n"), Meta: []byte("type: task\n")}, + {Key: "breaks", Schema: "task", Body: []byte("# Breaks\n\nfoo again\n"), Meta: []byte("type: task\nnote: foo\n")}, + }) + require.NoError(t, err) + typed, untyped, breaks := created[0].ID, created[1].ID, created[2].ID + // An untyped node, as one written before the keg turned strict is. + require.NoError(t, k.Repo.WriteMeta(ctx, untyped, []byte("tags: [foo]\n"))) + // Metadata that breaks the schema once "foo" in it is replaced. + require.NoError(t, k.Repo.WriteMeta(ctx, breaks, []byte("type: foo\n"))) + before := map[keg.NodeId]*keg.NodeView{} + for _, id := range []keg.NodeId{typed, untyped, breaks} { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + before[id] = view + } + opts := keg.ReplaceTextOptions{Find: "foo", Replace: "bar", IncludeMeta: true, DryRun: true} + errorsOf := func(r keg.ReplaceTextResult) map[keg.NodeId]string { + out := map[keg.NodeId]string{} + for _, n := range r.Nodes { + out[n.ID] = n.Error + } + return out + } + + dry, err := k.ReplaceText(ctx, opts) + require.NoError(t, err) + opts.DryRun = false + done, err := k.ReplaceText(ctx, opts) + require.NoError(t, err, "a node that would be refused does not fail the replace") + require.Equal(t, errorsOf(dry), errorsOf(done), "the dry run predicts the same refusals") + + for _, n := range done.Nodes { + after, err := k.ReadNode(ctx, n.ID) + require.NoError(t, err) + if n.Error == "" { + require.NotEmpty(t, n.Hash) + require.Contains(t, string(after.Content), "bar") + continue + } + require.Empty(t, n.Hash) + require.Equal(t, before[n.ID].Content, after.Content, "node %s is left alone", n.ID.Path()) + require.Equal(t, before[n.ID].Meta, after.Meta) + } + errs := errorsOf(done) + require.Empty(t, errs[typed]) + require.NotEmpty(t, errs[breaks], "metadata that breaks the schema is refused") + require.NotContains(t, errs[breaks], "invalid argument") +} + +func TestReplaceTextEditsALegacyFrontmatterBody(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + legacy, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# T\n")}) + require.NoError(t, err) + require.NoError(t, k.Repo.WriteContent(ctx, legacy.ID, []byte("---\na: 1\n---\n# T\n\nold word\n"))) + done, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "old word", Replace: "new word"}) + require.NoError(t, err) + require.Len(t, done.Nodes, 1) + require.Empty(t, done.Nodes[0].Error) + got, err := k.ReadNode(ctx, legacy.ID) + require.NoError(t, err) + require.Equal(t, "---\na: 1\n---\n# T\n\nnew word\n", string(got.Content)) +} + +func TestPurgeLeavesLinksToALiveNodeAlone(t *testing.T) { + t.Parallel() + k, repo, ctx := newTrashingKeg(t) + live, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Live\n")}) + require.NoError(t, err) + linkerBody := fmt.Sprintf("# Linker\n\n[live](../%d)\n", live.ID.ID) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(linkerBody)}) + require.NoError(t, err) + repo.plant(live.ID) + + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{live.ID}}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{live.ID}, result.Purged) + require.Empty(t, result.Rewritten) + got, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Equal(t, linkerBody, string(got.Content)) + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Empty(t, trash) +} + +func TestPurgeTrashedReportsWhatItPurged(t *testing.T) { + t.Parallel() + k, repo, ctx := newTrashingKeg(t) + remove := func(body string) keg.NodeId { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: created.ID, ExpectedHash: view.Hash()}) + require.NoError(t, err) + return created.ID + } + a := remove("# A\n") + b := remove("# B\n") + linkerA, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LA\n\n[a](../%d)\n", a.ID))}) + require.NoError(t, err) + linkerB, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LB\n\n[b](../%d)\n", b.ID))}) + require.NoError(t, err) + + now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + repo.expire(a, now.Add(-time.Hour)) + repo.expire(b, now.Add(time.Hour)) + result, err := k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{a, b}, ExpiredAt: &now}) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{a}, result.Purged, "only the expired entry goes") + require.Equal(t, []keg.NodeId{linkerA.ID}, result.Rewritten) + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{b}), "the unexpired one is still restorable") + + bView, err := k.ReadNode(ctx, b) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: b, ExpectedHash: bView.Hash()}) + require.NoError(t, err) + c := remove("# C\n") + linkerC, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# LC\n\n[c](../%d)\n", c.ID))}) + require.NoError(t, err) + result, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{b, c, {ID: 999}}}) + require.NoError(t, err) + require.ElementsMatch(t, []keg.NodeId{b, c}, result.Purged, "an id not in the trash is not reported purged") + require.ElementsMatch(t, []keg.NodeId{linkerB.ID, linkerC.ID}, result.Rewritten) + for _, id := range []keg.NodeId{linkerB.ID, linkerC.ID} { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + require.Contains(t, string(view.Content), "(../0)") + } + + ids := make([]keg.NodeId, keg.MaxMutationBatchSize+1) + for i := range ids { + ids[i] = keg.NodeId{ID: 1000 + i} + } + _, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: ids}) + require.ErrorIs(t, err, keg.ErrInvalid) +} + +// A replace is capped on every node it changes, refused ones included, and a +// refused node carries its error rather than its bodies. +func TestReplaceTextCapsRefusedNodesAndOmitsTheirBodies(t *testing.T) { + t.Parallel() + k, ctx := newStrictBatchKeg(t) + var ids []keg.NodeId + for len(ids) <= keg.MaxReplaceNodes { + batch := make([]keg.NodeCreate, 0, keg.MaxMutationBatchSize) + for i := 0; i < keg.MaxMutationBatchSize && len(ids)+len(batch) <= keg.MaxReplaceNodes; i++ { + batch = append(batch, keg.NodeCreate{Key: fmt.Sprintf("n%d-%d", len(ids), i), Schema: "task", Body: []byte("# N\n\nfoo\n"), Meta: []byte("type: task\n")}) + } + created, err := k.CreateNodes(ctx, batch) + require.NoError(t, err) + for _, c := range created { + ids = append(ids, c.ID) + } + } + // Untyped, as nodes written before the keg turned strict are: a strict + // keg refuses to edit them without a schema. + for _, id := range ids { + require.NoError(t, k.Repo.WriteMeta(ctx, id, []byte("tags: [x]\n"))) + } + _, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "foo", Replace: "bar", DryRun: true}) + require.ErrorIs(t, err, keg.ErrInvalid, "%d refused nodes still count against the cap", len(ids)) + + small, smallCtx := newStrictBatchKeg(t) + created, err := small.CreateNodes(smallCtx, []keg.NodeCreate{{Key: "a", Schema: "task", Body: []byte("# N\n\nfoo\n"), Meta: []byte("type: task\n")}}) + require.NoError(t, err) + require.NoError(t, small.Repo.WriteMeta(smallCtx, created[0].ID, []byte("tags: [x]\n"))) + few, err := small.ReplaceText(smallCtx, keg.ReplaceTextOptions{Find: "foo", Replace: "bar", DryRun: true}) + require.NoError(t, err) + require.Len(t, few.Nodes, 1) + n := few.Nodes[0] + require.NotEmpty(t, n.Error) + require.Equal(t, 1, n.Matches) + require.Empty(t, n.ContentBefore) + require.Empty(t, n.ContentAfter) + require.Empty(t, n.MetaBefore) + require.Empty(t, n.MetaAfter) +} + +// archiveOf builds a keg archive whose nodes sit at ids 1..len(bodies), each +// with its body, in a keg of its own. +func archiveOf(t *testing.T, bodies ...string) []byte { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + ids := make([]keg.NodeId, len(bodies)) + for i, body := range bodies { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + require.Equal(t, i+1, created.ID.ID) + ids[i] = created.ID + } + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: ids}) + require.NoError(t, err) + data, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + return data +} + +// archiveAt builds a keg archive holding one node at id with body. +func archiveAt(t *testing.T, id int, body string) []byte { + t.Helper() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + var last keg.NodeId + for last.ID < id { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + last = created.ID + } + archive, err := k.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{last}}) + require.NoError(t, err) + data, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, archive.Close()) + return data +} + +func trashNode(t *testing.T, ctx context.Context, k *keg.LocalKeg, id keg.NodeId) { + t.Helper() + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: id, ExpectedHash: view.Hash()}) + require.NoError(t, err) +} + +func onlyTrashEntry(t *testing.T, ctx context.Context, k *keg.LocalKeg) keg.TrashEntry { + t.Helper() + trash, err := k.ListTrash(ctx) + require.NoError(t, err) + require.Len(t, trash, 1) + return trash[0] +} + +// A trashed node displaced twice keeps its original id on record and +// restores at its current id: the live node at its original id is never +// touched. +func TestTwiceDisplacedEntryRestoresAtItsCurrentID(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Trashed\n\nOriginal.\n")}) + require.NoError(t, err) + require.Equal(t, 1, first.ID.ID) + trashNode(t, ctx, k, first.ID) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveAt(t, 1, "# Archived one\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + entry := onlyTrashEntry(t, ctx, k) + require.Equal(t, first.ID, *entry.DisplacedFrom) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveAt(t, entry.ID.ID, "# Archived other\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + moved := onlyTrashEntry(t, ctx, k) + require.NotEqual(t, entry.ID, moved.ID) + require.Equal(t, first.ID, *moved.DisplacedFrom, "the original id is kept") + + live, err := k.ReadNode(ctx, first.ID) + require.NoError(t, err) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: first.ID, Content: []byte("# Archived one\n\nEdited.\n"), HasContent: true, ExpectedContentHash: live.ContentHash()}}) + require.NoError(t, err) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{moved.ID})) + back, err := k.ReadNode(ctx, moved.ID) + require.NoError(t, err) + require.Equal(t, "# Trashed\n\nOriginal.\n", string(back.Content)) + edited, err := k.ReadNode(ctx, first.ID) + require.NoError(t, err) + require.Equal(t, "# Archived one\n\nEdited.\n", string(edited.Content), "the live node is untouched") +} + +// An archive whose ids run past the counter: the trashed node is displaced +// to an id the archive does not use, and the imported nodes stay as +// imported. +func TestDisplacementSkipsTheArchivesOwnIDs(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Trashed\n")}) + require.NoError(t, err) + _, err = k.Create(ctx, &keg.CreateOptions{Body: []byte("# Two\n")}) + require.NoError(t, err) + trashNode(t, ctx, k, first.ID) + + _, err = k.ImportNodes(ctx, bytes.NewReader(archiveOf(t, "# A1\n", "# A2\n", "# A3\n", "# A4\n")), keg.ImportNodesOptions{}) + require.NoError(t, err) + entry := onlyTrashEntry(t, ctx, k) + require.Greater(t, entry.ID.ID, 4, "the entry is not given one of the archive's ids") + require.Equal(t, first.ID, *entry.DisplacedFrom) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{entry.ID})) + back, err := k.ReadNode(ctx, entry.ID) + require.NoError(t, err) + require.Equal(t, "# Trashed\n", string(back.Content)) + for i, want := range []string{"# A1\n", "# A2\n", "# A3\n", "# A4\n"} { + got, err := k.ReadNode(ctx, keg.NodeId{ID: i + 1}) + require.NoError(t, err) + require.Equal(t, want, string(got.Content)) + } +} + +// A trash restore never overwrites a live node: with a node at the entry's +// id, it is refused and nothing changes. +func TestRestoreOntoALiveNodeIsRefused(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + trashNode(t, ctx, k, gone.ID) + // A node at the trashed id, as legacy data or a direct write leaves one. + require.NoError(t, k.Repo.WriteContent(ctx, gone.ID, []byte("# Squatter\n"))) + require.NoError(t, k.Repo.WriteMeta(ctx, gone.ID, []byte("{}\n"))) + + err = k.RestoreTrashed(ctx, []keg.NodeId{gone.ID}) + require.ErrorIs(t, err, keg.ErrDestinationExists) + got, err := k.ReadNode(ctx, gone.ID) + require.NoError(t, err) + require.Equal(t, "# Squatter\n", string(got.Content)) + require.Equal(t, gone.ID, onlyTrashEntry(t, ctx, k).ID) +} + +// A replace measures what it would build before building it: a node that +// would grow too large, or has too many matches, is left alone with an +// error, and a replace that would grow every node together too large fails +// before writing any. +func TestReplaceTextBoundsWhatItBuilds(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + read := func(id keg.NodeId) string { + view, err := k.ReadNode(ctx, id) + require.NoError(t, err) + return string(view.Content) + } + + small, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Small\n\n" + strings.Repeat("word ", 40) + "\n")}) + require.NoError(t, err) + wide := strings.Repeat("W", 64<<10) + for _, dryRun := range []bool{true, false} { + // Matching the empty string matches at every position. + got, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "x*", Replace: wide, Regex: true, DryRun: dryRun}) + require.NoError(t, err) + require.NotEmpty(t, got.Nodes) + for _, node := range got.Nodes { + require.Contains(t, node.Error, "MiB", "node %s", node.ID.Path()) + require.Empty(t, node.ContentAfter) + } + require.NotContains(t, read(small.ID), "WWW", "the oversized note is left alone") + } + + many, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Many\n\n" + strings.Repeat("q", keg.MaxReplaceNodeMatches+1) + "\n")}) + require.NoError(t, err) + got, err := k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "q", Replace: "r"}) + require.NoError(t, err) + require.Len(t, got.Nodes, 1) + require.Contains(t, got.Nodes[0].Error, "matches") + require.Contains(t, read(many.ID), "qqq") + + perNode := keg.MaxReplaceNodeBytes / 2 + var ids []keg.NodeId + for written := 0; written <= keg.MaxReplaceTotalBytes; written += perNode { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Big\n\nZZ\n")}) + require.NoError(t, err) + ids = append(ids, created.ID) + } + _, err = k.ReplaceText(ctx, keg.ReplaceTextOptions{Find: "ZZ", Replace: strings.Repeat("z", perNode)}) + require.ErrorIs(t, err, keg.ErrInvalid) + for _, id := range ids { + require.Contains(t, read(id), "ZZ", "a replace too large in all writes nothing") + } +} + +// readCountingRepo is a trash that counts reads of each node's content. +type readCountingRepo struct { + *trashingRepo + countMu sync.Mutex + reads map[keg.NodeId]int +} + +func (r *readCountingRepo) ReadContent(ctx context.Context, id keg.NodeId) ([]byte, error) { + r.countMu.Lock() + r.reads[id]++ + r.countMu.Unlock() + return r.trashingRepo.ReadContent(ctx, id) +} + +func (r *readCountingRepo) resetReads() { + r.countMu.Lock() + defer r.countMu.Unlock() + r.reads = map[keg.NodeId]int{} +} + +func (r *readCountingRepo) readsOf(id keg.NodeId) int { + r.countMu.Lock() + defer r.countMu.Unlock() + return r.reads[id] +} + +// Restoring a batch from the trash costs the nodes restored, not the whole +// keg: no other node's content is read, yet the indexes come out as a full +// rebuild would leave them, with the links other nodes hold to the restored +// ones back in place. +func TestRestoreTrashedReadsOnlyWhatItRestores(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &readCountingRepo{trashingRepo: &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}}, reads: map[keg.NodeId]int{}} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + var bystanders []keg.NodeId + for i := range 20 { + created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Bystander %d\n", i))}) + require.NoError(t, err) + bystanders = append(bystanders, created.ID) + } + first, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# First\n")}) + require.NoError(t, err) + second, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Second\n\nSee [first](../%d).\n", first.ID.ID))}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[first](../%d) and [second](../%d)\n", first.ID.ID, second.ID.ID))}) + require.NoError(t, err) + trashNode(t, ctx, k, first.ID) + trashNode(t, ctx, k, second.ID) + + repo.resetReads() + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{first.ID, second.ID})) + for _, id := range bystanders { + require.Zero(t, repo.readsOf(id), "restoring read bystander %s", id.Path()) + } + + dex, err := k.Dex(ctx) + require.NoError(t, err) + backlinks, _ := dex.Backlinks(ctx, first.ID) + require.ElementsMatch(t, []keg.NodeId{second.ID, linker.ID}, backlinks) + backlinks, _ = dex.Backlinks(ctx, second.ID) + require.ElementsMatch(t, []keg.NodeId{linker.ID}, backlinks) + + restored := map[string][]byte{} + for _, name := range []string{"links", "backlinks", "nodes.tsv", "tags"} { + data, err := k.ReadIndex(ctx, name) + require.NoError(t, err) + restored[name] = data + } + require.NoError(t, k.Index(ctx, keg.IndexOptions{})) + for name, data := range restored { + rebuilt, err := k.ReadIndex(ctx, name) + require.NoError(t, err) + require.Equal(t, string(rebuilt), string(data), "index %s after a restore differs from a rebuild", name) + } +} + +// A restored node's links to a node purged while it sat in the trash point +// at node 0, as purging pointed the live nodes' links; links to a live node +// or to another trashed node are kept. +func TestRestoreTrashedPointsLinksToPurgedNodesAtZero(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + live, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Live\n")}) + require.NoError(t, err) + purged, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Purged\n")}) + require.NoError(t, err) + sleeping, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Sleeping\n")}) + require.NoError(t, err) + body := fmt.Sprintf("# Back\n\n[live](../%d) [purged](../%d) [sleeping](../%d)\n", live.ID.ID, purged.ID.ID, sleeping.ID.ID) + back, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) + require.NoError(t, err) + + trashNode(t, ctx, k, back.ID) + trashNode(t, ctx, k, purged.ID) + trashNode(t, ctx, k, sleeping.ID) + _, err = k.PurgeTrashed(ctx, keg.PurgeTrashOptions{IDs: []keg.NodeId{purged.ID}}) + require.NoError(t, err) + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{back.ID})) + view, err := k.ReadNode(ctx, back.ID) + require.NoError(t, err) + want := fmt.Sprintf("# Back\n\n[live](../%d) [purged](../0) [sleeping](../%d)\n", live.ID.ID, sleeping.ID.ID) + require.Equal(t, want, string(view.Content)) +} + +// limitedTrashRepo is a trash that holds bundles up to limit bytes. +type limitedTrashRepo struct { + *trashingRepo + limit int +} + +func (r *limitedTrashRepo) TrashBundleLimit() int { return r.limit } + +// A node too large for the trash is refused with a clear error and stays +// where it is; the rest of a removal batch is untouched. +func TestTrashRefusesANodeTooLargeForIt(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := &limitedTrashRepo{trashingRepo: &trashingRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime()), trash: map[keg.NodeId]trashedNode{}}, limit: 64 << 10} + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + small, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Small\n")}) + require.NoError(t, err) + big, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Big\n")}) + require.NoError(t, err) + // Random bytes do not compress, so the bundle passes the limit. + payload := make([]byte, 256<<10) + _, err = rand.Read(payload) + require.NoError(t, err) + require.NoError(t, k.WriteFile(ctx, big.ID, "blob.bin", payload)) + + view, err := k.ReadNode(ctx, big.ID) + require.NoError(t, err) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: big.ID, ExpectedHash: view.Hash()}) + require.ErrorIs(t, err, keg.ErrTrashBundleTooLarge) + require.ErrorIs(t, err, keg.ErrInvalid) + require.Contains(t, err.Error(), "cannot go to the trash") + exists, err := k.NodeExists(ctx, big.ID) + require.NoError(t, err) + require.True(t, exists, "a node refused by the trash stays live") + + result, err := k.TrashNodes(ctx, []keg.NodeId{small.ID, big.ID}, keg.TrashReasonArchiveRestore) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{small.ID}, result.Trashed) + require.Equal(t, []keg.NodeId{big.ID}, result.TooLarge) + entry := onlyTrashEntry(t, ctx, k) + require.Equal(t, small.ID, entry.ID) + require.Equal(t, keg.TrashReasonArchiveRestore, entry.Reason) +} + +// TrashNodes trashes live nodes for a reason of its own, skips ids with no +// node, refuses node 0 and a merge reason, and leaves links alone. +func TestTrashNodesKeepsNodesRestorable(t *testing.T) { + t.Parallel() + k, _, ctx := newTrashingKeg(t) + gone, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Gone\n")}) + require.NoError(t, err) + linker, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(fmt.Sprintf("# Linker\n\n[gone](../%d)\n", gone.ID.ID))}) + require.NoError(t, err) + + _, err = k.TrashNodes(ctx, []keg.NodeId{gone.ID}, keg.TrashReasonMerge) + require.ErrorIs(t, err, keg.ErrInvalid) + _, err = k.TrashNodes(ctx, []keg.NodeId{{ID: 0}}, keg.TrashReasonArchiveRestore) + require.ErrorIs(t, err, keg.ErrInvalid) + + result, err := k.TrashNodes(ctx, []keg.NodeId{gone.ID, {ID: 9999}}, keg.TrashReasonArchiveRestore) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{gone.ID}, result.Trashed) + exists, err := k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.False(t, exists) + linking, err := k.ReadNode(ctx, linker.ID) + require.NoError(t, err) + require.Contains(t, string(linking.Content), fmt.Sprintf("../%d)", gone.ID.ID)) + dex, err := k.Dex(ctx) + require.NoError(t, err) + require.Nil(t, dex.GetRef(ctx, gone.ID), "a trashed node leaves the dex") + + require.NoError(t, k.RestoreTrashed(ctx, []keg.NodeId{gone.ID})) + exists, err = k.NodeExists(ctx, gone.ID) + require.NoError(t, err) + require.True(t, exists) +} diff --git a/pkg/mcp/flight_search_test.go b/pkg/mcp/flight_search_test.go deleted file mode 100644 index 9b457e2f..00000000 --- a/pkg/mcp/flight_search_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package mcp - -import ( - "fmt" - "testing" - - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -func TestFlightSearchLiteralBoundedMetadata(t *testing.T) { - rows := []*tapper.Flight{} - for i := 60; i >= 0; i-- { - rows = append(rows, &tapper.Flight{Name: fmt.Sprintf("@n/+f%02d", i), FlightManifest: tapper.FlightManifest{Title: "Title", Description: "literal %_ [x]", Instructions: "instruction-only"}}) - } - out := SearchIdentityFlights(rows, "%_") - require.Len(t, out.Flights, 50) - require.True(t, out.Truncated) - require.Equal(t, "@n/+f00", out.Flights[0].Ref) - require.Empty(t, SearchIdentityFlights(rows, "instruction-only").Flights) - require.Empty(t, SearchIdentityFlights(rows, " ").Flights) - require.Len(t, SearchIdentityFlights(rows, "@n/+f60").Flights, 1) - require.Len(t, SearchIdentityFlights(rows, "TITLE").Flights, 50) -} diff --git a/pkg/mcp/orientation_cache_test.go b/pkg/mcp/orientation_cache_test.go index 6af99666..77e8881c 100644 --- a/pkg/mcp/orientation_cache_test.go +++ b/pkg/mcp/orientation_cache_test.go @@ -20,7 +20,7 @@ func newCachedFlightSession(t *testing.T, backend *perCallFlightBackend, ttl tim require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, OrientationCacheTTL: ttl, }) return connectFlightSession(t, ctx, server, nil), ctx, func(d time.Duration) { sb.Advance(d) } diff --git a/pkg/mcp/providers.go b/pkg/mcp/providers.go index 47f2ce9a..66402840 100644 --- a/pkg/mcp/providers.go +++ b/pkg/mcp/providers.go @@ -111,14 +111,11 @@ type AgentProvider interface { DeleteAgent(ctx context.Context, ref string) error } -// NamespaceProvider discovers namespaces: the caller's own, and a search -// across the hub. Discovery never grants authority. +// NamespaceProvider lists the caller's own namespaces. The hub-wide people +// directory is ExploreProvider's. Listing never grants authority. type NamespaceProvider interface { // ListNamespaces returns the namespaces the caller belongs to, with role. ListNamespaces(context.Context) ([]tapper.HubNamespace, error) - // SearchNamespaces returns a bounded page of namespaces visible on the - // hub whose name or display name matches query; empty browses. - SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) } // NodeSearchProvider ranks nodes by relevance across the KEGs a call may @@ -136,34 +133,6 @@ func (p localNodeSearchProvider) SearchNodes(ctx context.Context, opts tapper.Se 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 { - Ref string `json:"ref"` - Role string `json:"role"` - Title string `json:"title"` - 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 -// results from reachable hubs. -type KegSearchResult struct { - Kegs []KegSearchRow `json:"kegs"` - Warnings []string `json:"warnings"` - Truncated bool `json:"truncated"` - Partial bool `json:"partial"` -} - -// KegSearchProvider searches identity-authorized KEG metadata independently -// of flight authority. -type KegSearchProvider interface { - // SearchKegs returns bounded identity-authorized metadata matches. - SearchKegs(context.Context, string) (KegSearchResult, error) -} - // AuthIdentity is deliberately credential-free. Do not add token, email, // scope, cookie, expiry, or session fields to this MCP wire shape. type AuthIdentity struct { @@ -671,49 +640,6 @@ func (p localKegDiscoveryProvider) CreateKeg(ctx context.Context, opts tapper.Cr return opts.Keg, nil } -func (p localKegDiscoveryProvider) SearchKegs(ctx context.Context, query string) (KegSearchResult, error) { - rows, warnings, err := p.tap.IdentityKegCatalog(ctx) - if err != nil { - return KegSearchResult{}, err - } - out := SearchIdentityKegsResult(rows, query) - out.Warnings = append([]string{}, warnings...) - out.Partial = len(warnings) > 0 - return out, nil -} - -// SearchIdentityKegs performs case-insensitive literal matching over canonical -// ref, title, and description, returning at most 50 canonically ordered rows. -func SearchIdentityKegs(rows []tapper.OrientationKeg, query string) []KegSearchRow { - return SearchIdentityKegsResult(rows, query).Kegs -} - -// SearchIdentityKegsResult retains truncation information for MCP callers. -func SearchIdentityKegsResult(rows []tapper.OrientationKeg, query string) KegSearchResult { - needle := strings.ToLower(strings.TrimSpace(query)) - if needle == "" { - return KegSearchResult{Kegs: []KegSearchRow{}, Warnings: []string{}} - } - matched := make([]KegSearchRow, 0, len(rows)) - for _, row := range rows { - haystack := strings.ToLower(row.Ref + "\n" + row.Title + "\n" + row.Description) - if !strings.Contains(haystack, needle) { - continue - } - 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 }) - out := KegSearchResult{Kegs: matched, Warnings: []string{}, Truncated: len(matched) > 50} - if out.Truncated { - out.Kegs = out.Kegs[:50] - } - return out -} - type localAgentProvider struct{ tap *tapper.Tap } func (p localAgentProvider) ListAgents(ctx context.Context, namespace string) ([]tapper.HubAgent, error) { @@ -738,9 +664,6 @@ func (p localNamespaceProvider) ListNamespaces(ctx context.Context) ([]tapper.Hu res, err := p.tap.NamespaceList(ctx, tapper.NamespaceListOptions{}) return res.Namespaces, err } -func (p localNamespaceProvider) SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) { - return p.tap.NamespaceSearch(ctx, tapper.NamespaceSearchOptions{Query: query}) -} func (p localNamespaceProvider) ListInvitations(ctx context.Context) ([]tapper.HubInvitation, error) { return p.tap.InvitationList(ctx, tapper.InvitationOptions{}) } diff --git a/pkg/mcp/public_response_test.go b/pkg/mcp/public_response_test.go index f1c62040..ecb10fec 100644 --- a/pkg/mcp/public_response_test.go +++ b/pkg/mcp/public_response_test.go @@ -1,6 +1,7 @@ package mcp_test import ( + "context" "encoding/json" "fmt" "regexp" @@ -182,14 +183,17 @@ func TestPublicContractGuideInventory(t *testing.T) { require.Contains(t, wirePayload(t, invalid, "structured")["message"], "operating") } -func TestPublicContractSearchTruncation(t *testing.T) { - rows := []tapper.OrientationKeg{} - for i := 0; i < 51; i++ { - rows = append(rows, tapper.OrientationKeg{Ref: fmt.Sprintf("@team/notes%d", i), Title: "Match"}) - } - out := mcp.SearchIdentityKegsResult(rows, "Match") - require.Len(t, out.Kegs, 50) - require.True(t, out.Truncated) - require.False(t, out.Partial) - require.False(t, mcp.SearchIdentityKegsResult(rows[:50], "Match").Truncated) +type truncatedExplore struct{} + +func (truncatedExplore) Explore(_ context.Context, kind tapper.ExploreKind, _ string) (tapper.ExploreResult, error) { + return tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{{Ref: "@team/notes", Name: "Notes"}}, Truncated: true}, nil +} + +func TestPublicContractExploreTruncation(t *testing.T) { + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{ExploreProvider: truncatedExplore{}}) + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "notes"}}) + require.NoError(t, err) + require.False(t, res.IsError) + require.True(t, wirePayload(t, res, "structured")["truncated"].(bool)) + require.Contains(t, extractText(t, res), "Results truncated; refine the query.") } diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index eb65035f..d4f261b5 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -37,7 +37,7 @@ type ServerOptions struct { OrientationProvider OrientationProvider FlightProvider FlightProvider KegProvider KegDiscoveryProvider - KegSearchProvider KegSearchProvider + ExploreProvider ExploreProvider IdentityProvider IdentityProvider AgentProvider AgentProvider NamespaceProvider NamespaceProvider @@ -76,8 +76,8 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se if opt.KegProvider == nil { opt.KegProvider = localKegDiscoveryProvider{tap: tap} } - if opt.KegSearchProvider == nil { - opt.KegSearchProvider = localKegDiscoveryProvider{tap: tap} + if opt.ExploreProvider == nil { + opt.ExploreProvider = localExploreProvider{tap: tap} } if opt.IdentityProvider == nil { opt.IdentityProvider = localIdentityProvider{tap: tap} @@ -129,13 +129,15 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se registerWriteTools(srv, tap, defaults) registerIndexTools(srv, tap, defaults) registerSnapshotTools(srv, tap, defaults) + registerTrashTools(srv, tap, defaults) registerOrientTools(srv, tap, defaults) registerSchemaTools(srv, tap, defaults) registerFileTools(srv, tap, defaults, opt.SharedFilesystem) registerDoctorTools(srv, tap, defaults) registerLockTools(srv, tap, defaults) registerFlightTools(srv, defaults, opt.FlightProvider) - registerKegTools(srv, defaults, opt.KegProvider, opt.KegSearchProvider) + registerKegTools(srv, defaults, opt.KegProvider) + registerExploreTools(srv, opt.ExploreProvider) registerResourceTools(srv, tap, defaults) registerAuthInfoTool(srv, defaults, opt.IdentityProvider, opt.KegProvider) registerAgentTools(srv, defaults, opt.AgentProvider) @@ -352,7 +354,7 @@ 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`/`node_grep` 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` for flights, or `keg_list` 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: @@ -370,7 +372,7 @@ func errorGuidance(code string) (action string, operationPerformed any) { 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 + 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 labels 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 abdea00c..16849f7e 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", "node_grep", "tag_list", "node_backlinks", "node_links", "keg_info", + "session_info", "keg_list", "explore_kegs", "explore_flights", "explore_people", "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", @@ -249,7 +249,7 @@ func TestMCP_ToolsList(t *testing.T) { "flight_create", "flight_edit", "flight_delete", "schema_list", "schema_read", "schema_create", "schema_edit", "schema_delete", "schema_validate", "agent_list", "agent_read", "agent_create", "agent_edit", "agent_delete", - "namespace_list", "namespace_search", + "namespace_list", } { require.Truef(t, names[want], "agent-safe surface missing %q", want) } @@ -292,11 +292,11 @@ func TestMCP_CommonAgentSafeSurface(t *testing.T) { "file_list", "image_list", "file_delete", "image_delete", "file_upload", "image_upload", "image_download", "schema_list", "schema_read", "schema_create", "schema_edit", - "schema_delete", "schema_validate", "keg_check", "keg_list", "keg_search", "session_info", + "schema_delete", "schema_validate", "keg_check", "keg_list", "explore_kegs", "explore_flights", "explore_people", "session_info", "lock_acquire", "lock_release", "lock_status", "lock_force_release", "flight_list", "flight_read", "flight_create", "flight_edit", "flight_delete", "agent_list", "agent_read", "agent_create", "agent_edit", "agent_delete", - "namespace_list", "namespace_search", + "namespace_list", } { require.Truef(t, names[want], "common surface should expose %q", want) } @@ -1054,7 +1054,92 @@ func TestMCP_EditRequiresContentOrMeta(t *testing.T) { }) require.NoError(t, err) require.True(t, res.IsError, "edit accepted an item with neither content nor meta") - require.Contains(t, extractText(t, res), "content or meta is required") + require.Contains(t, extractText(t, res), "content, patch, or meta is required") +} + +func TestMCP_EditAppliesPatch(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + createRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_create", + Arguments: batchCreateArgs(map[string]any{"title": "Patch Subject"}), + }) + require.NoError(t, err) + nodeID := extractText(t, createRes) + editRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "content": "# Patch Subject\n\n## Status\nDraft\n\nKeep this line.\n", + "expected_content_hash": readContentHash(t, session, ctx, nodeID), + }), + }) + require.NoError(t, err) + require.False(t, editRes.IsError, "edit returned error: %s", extractText(t, editRes)) + + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "patch": "@@ -3,2 +3,2 @@\n ## Status\n-Draft\n+Published\n", + "expected_content_hash": readContentHash(t, session, ctx, nodeID), + }), + }) + require.NoError(t, err) + require.False(t, res.IsError, "patch returned error: %s", extractText(t, res)) + + readRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_read", + Arguments: map[string]any{"node_ids": []string{nodeID}, "content_only": true}, + }) + require.NoError(t, err) + body := nodeReadText(t, readRes) + require.Contains(t, body, "## Status\nPublished\n") + require.NotContains(t, body, "Draft") + require.Contains(t, body, "Keep this line.") +} + +func TestMCP_EditRefusesPatchesItCannotApply(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + createRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "node_create", + Arguments: batchCreateArgs(map[string]any{"title": "Unpatched"}), + }) + require.NoError(t, err) + nodeID := extractText(t, createRes) + hash := readContentHash(t, session, ctx, nodeID) + cases := []struct { + name string + item map[string]any + contains string + }{ + { + name: "context not in the note", + item: map[string]any{"patch": "@@ -1 +1 @@\n-# Not the title\n+# New\n", "expected_content_hash": hash}, + contains: "do not match", + }, + { + name: "no content token", + item: map[string]any{"patch": "@@ -1 +1 @@\n-# Unpatched\n+# New\n"}, + contains: "expected_content_hash", + }, + { + name: "content and patch together", + item: map[string]any{"content": "# New\n", "patch": "@@ -1 +1 @@\n-# Unpatched\n+# New\n", "expected_content_hash": hash}, + contains: "alternatives", + }, + } + for _, tc := range cases { + tc.item["node_id"] = nodeID + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "node_edit", Arguments: batchEditArgs(tc.item)}) + require.NoError(t, err, tc.name) + require.True(t, res.IsError, "%s: edit was accepted", tc.name) + require.Contains(t, extractText(t, res), tc.contains, tc.name) + } + require.Equal(t, hash, readContentHash(t, session, ctx, nodeID), "a refused patch changed the note") } // TestMCP_EditRejectsSnapshotBefore pins the removal of the implicit snapshot diff --git a/pkg/mcp/session_bootstrap_test.go b/pkg/mcp/session_bootstrap_test.go index 71b50358..84dedab3 100644 --- a/pkg/mcp/session_bootstrap_test.go +++ b/pkg/mcp/session_bootstrap_test.go @@ -58,9 +58,9 @@ func TestMCP_NoFlightsAnywhereUsesIdentityFullAccess(t *testing.T) { require.Contains(t, tools, "flight_create") require.Contains(t, tools, "keg_create") require.Contains(t, tools, "keg_delete") - search, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": "anything"}}) + listed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) require.NoError(t, err) - require.False(t, search.IsError, extractText(t, search)) + require.False(t, listed.IsError, extractText(t, listed)) created, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ "keg": "@local/first", "title": "First KEG", diff --git a/pkg/mcp/session_flight.go b/pkg/mcp/session_flight.go index a70e91b4..290030fa 100644 --- a/pkg/mcp/session_flight.go +++ b/pkg/mcp/session_flight.go @@ -46,7 +46,7 @@ func failedOrientationPayload(err error) string { } return "This session could not establish flight authority: " + err.Error() + "\n\nKEG tools are locked until it does, so only `orient`, `session_refresh`, `flight_list`," + - " `flight_read`, `session_info`, `keg_search`, `flight_search`, and `guide` are published. An empty cover on a" + + " `flight_read`, `session_info`, and `guide` are published. An empty cover on a" + " successfully loaded flight would still publish the complete registered" + " inventory. Call `flight_list` to see what" + " actually exists, then ask the user to correct the selected flight (in" + @@ -56,27 +56,26 @@ func failedOrientationPayload(err error) string { } var recoveryToolNames = map[string]bool{ - "guide": true, "flight_search": true, + "guide": true, "orient": true, "session_refresh": true, "flight_list": true, "flight_read": true, "session_info": true, - "keg_search": true, } // ungovernedToolNames never select authority and therefore do not advertise a // flight argument. Keep administration/configuration discovery here even when // a particular build does not register those tools. var ungovernedToolNames = map[string]bool{ - "guide": true, "flight_search": true, - "session_info": true, "auth_status": true, "keg_search": true, + "guide": true, "explore_kegs": true, "explore_flights": true, "explore_people": true, + "session_info": true, "auth_status": true, "session_refresh": true, "config": true, "config_template": true, "namespace_list": true, "namespace_create": true, "namespace_members": true, "namespace_add_member": true, "namespace_set_role": true, "namespace_remove_member": true, "license": true, "flight_list": true, "flight_read": true, - "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true, + "invitation_list": true, "agent_list": true, "agent_read": true, } // sessionMode is the authority state of one MCP session. diff --git a/pkg/mcp/session_flight_test.go b/pkg/mcp/session_flight_test.go index 45ef70da..97b44750 100644 --- a/pkg/mcp/session_flight_test.go +++ b/pkg/mcp/session_flight_test.go @@ -141,7 +141,8 @@ func TestMCP_OrientRepeatsTheRulesInitializationAlreadySent(t *testing.T) { oriented := callOrient(t, ctx, session) require.Contains(t, oriented, "# KEG System") require.Contains(t, oriented, "Use only `mcp__tapper__*` tools") - require.Contains(t, oriented, "`flight_search`") + require.Contains(t, oriented, "`flight_list`") + require.Contains(t, oriented, "`explore_flights`") require.NotContains(t, oriented, "## Available KEGs") } @@ -240,10 +241,10 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t rootList, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) require.NoError(t, err) require.False(t, rootList.IsError, extractText(t, rootList)) - require.Equal(t, "@admin/private\teditor\t@admin/+test", extractText(t, rootList)) + require.Equal(t, "@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg", extractText(t, rootList)) rootStructured, err := json.Marshal(rootList.StructuredContent) require.NoError(t, err) - require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test"}`, string(rootStructured)) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","title":"Private","description":"Covered child keg","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg"}`, string(rootStructured)) explicitRoot, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "@admin/+admin"}}) require.NoError(t, err) @@ -253,10 +254,10 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t beforeSelected := catalogRequests.Load() selected, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+test"}}) require.NoError(t, err) - require.Equal(t, "@admin/private\teditor\t@admin/+test", extractText(t, selected)) + require.Equal(t, "@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg", extractText(t, selected)) selectedStructured, err := json.Marshal(selected.StructuredContent) require.NoError(t, err) - require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test"}`, string(selectedStructured)) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","title":"Private","description":"Covered child keg","flights":["@admin/+test"]}],"message":"@admin/private\teditor\t@admin/+test\tPrivate\tCovered child keg"}`, string(selectedStructured)) require.Equal(t, beforeSelected+1, catalogRequests.Load(), "one active Hub catalog projection") deniedOperation, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_settings_read", Arguments: map[string]any{"keg": "@admin/private"}}) @@ -276,13 +277,6 @@ func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t require.NoError(t, err) require.NotContains(t, extractText(t, explicitRootOrient), "`@admin/private`") - for _, query := range []string{"ecw", "EXAMPLE", "covered child"} { - found, searchErr := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": query}}) - require.NoError(t, searchErr) - require.False(t, found.IsError, extractText(t, found)) - require.NotEmpty(t, extractText(t, found)) - } - beforeInvalid := catalogRequests.Load() invalid, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"all": true}}) require.NoError(t, err) diff --git a/pkg/mcp/session_transition_test.go b/pkg/mcp/session_transition_test.go index a2e17a73..741e6a29 100644 --- a/pkg/mcp/session_transition_test.go +++ b/pkg/mcp/session_transition_test.go @@ -311,19 +311,14 @@ func (p *perCallFlightBackend) CreateKeg(_ context.Context, opts tapper.CreateKe return ref, nil } -func (p *perCallFlightBackend) SearchKegs(_ context.Context, query string) (mcp.KegSearchResult, error) { - p.mu.Lock() - defer p.mu.Unlock() - rows := make([]tapper.OrientationKeg, 0, len(p.kegs)) - for _, ref := range p.kegs { - namespaceAlias := strings.TrimPrefix(ref, "@") - namespace, alias, _ := strings.Cut(namespaceAlias, "/") - rows = append(rows, tapper.OrientationKeg{ - Ref: ref, Namespace: namespace, Alias: alias, Role: "admin", Source: "test", Visibility: "private", - Title: alias, Description: "summary for " + alias, - }) +// Explore answers from a fixed public directory that holds none of the +// backend's own KEGs, so a test can tell a public match from a private one. +func (p *perCallFlightBackend) Explore(_ context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + out := tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{}} + if kind == tapper.ExploreKegs && strings.Contains("public-notes", strings.ToLower(query)) { + out.Results = append(out.Results, tapper.ExploreRow{Ref: "@world/public-notes", Name: "Public notes", Description: "shared with everyone"}) } - return mcp.KegSearchResult{Kegs: mcp.SearchIdentityKegs(rows, query)}, nil + return out, nil } func (p *perCallFlightBackend) Identities(context.Context) ([]mcp.AuthIdentity, error) { @@ -338,7 +333,7 @@ func newPerCallFlightSession(t *testing.T, backend *perCallFlightBackend) (*sdkm require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, }) return connectFlightSession(t, ctx, server, nil), ctx } @@ -351,7 +346,7 @@ func newRefreshFlightSession(t *testing.T, backend *refreshFlightBackend, opts * require.NoError(t, err) server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, - KegSearchProvider: backend, IdentityProvider: backend, + ExploreProvider: backend, IdentityProvider: backend, }) return connectFlightSession(t, ctx, server, opts), ctx } @@ -501,41 +496,32 @@ func TestAggregateOrientationKegsMergesEqualWinningFlightProvenance(t *testing.T require.Equal(t, []string{"@team/+child", "@team/+root"}, rows[0].Flights) } -func TestMCP_KegSearchIsIdentityScopedLiteralBoundedAndUngoverned(t *testing.T) { +func TestMCP_ExploreKegsSearchesOnlyThePublicDirectory(t *testing.T) { backend := newPerCallFlightBackend() session, ctx := newPerCallFlightSession(t, backend) - found, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": "NEW-KEG"}}) + found, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "PUBLIC"}}) require.NoError(t, err) require.False(t, found.IsError, extractText(t, found)) - require.Equal(t, "@team/new-keg\tadmin\tnew-keg\tsummary for new-keg\tprivate\ttest", extractText(t, found)) - require.NotContains(t, extractText(t, found), "flight") + require.Equal(t, "@world/public-notes\tPublic notes\tshared with everyone\t", extractText(t, found)) raw, err := json.Marshal(found.StructuredContent) require.NoError(t, err) - var structured mcp.KegSearchResult + var structured tapper.ExploreResult require.NoError(t, json.Unmarshal(raw, &structured)) - require.Equal(t, []mcp.KegSearchRow{{ - Ref: "@team/new-keg", Role: "admin", Title: "new-keg", - Description: "summary for new-keg", Visibility: "private", Source: "test", - }}, structured.Kegs) + require.Equal(t, tapper.ExploreKegs, structured.Kind) + require.Equal(t, []tapper.ExploreRow{{Ref: "@world/public-notes", Name: "Public notes", Description: "shared with everyone"}}, structured.Results) - empty, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": " "}}) + private, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "explore_kegs", Arguments: map[string]any{"query": "new-keg"}}) require.NoError(t, err) - require.True(t, empty.IsError) - require.Contains(t, extractText(t, empty), "query must not be empty") - - rows := make([]tapper.OrientationKeg, 0, 60) - for i := 59; i >= 0; i-- { - alias := fmt.Sprintf("match-%02d", i) - rows = append(rows, tapper.OrientationKeg{ - Ref: "@team/" + alias, Namespace: "team", Alias: alias, - Title: "A match", Description: "literal metadata", Role: "viewer", - }) + require.False(t, private.IsError, extractText(t, private)) + require.Empty(t, extractText(t, private), "the session's own KEGs are listed by keg_list, never explored") + + for _, tool := range []string{"explore_kegs", "explore_flights", "explore_people"} { + empty, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: tool, Arguments: map[string]any{"query": " "}}) + require.NoError(t, err) + require.True(t, empty.IsError, tool) + require.Contains(t, extractText(t, empty), "query must not be empty", tool) } - bounded := mcp.SearchIdentityKegs(rows, "MATCH") - require.Len(t, bounded, 50) - require.Equal(t, "@team/match-00", bounded[0].Ref) - require.Equal(t, "@team/match-49", bounded[49].Ref) } func TestMCP_PerCallSelectionAdoptsGraphAuthorityChanges(t *testing.T) { @@ -630,7 +616,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t session, ctx := newPerCallFlightSession(t, backend) result, err := session.ListTools(ctx, nil) require.NoError(t, err) - ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true} + ungoverned := map[string]bool{"guide": true, "explore_kegs": true, "explore_flights": true, "explore_people": true, "session_info": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "invitation_list": true, "agent_list": true, "agent_read": true} seen := map[string]bool{} for _, tool := range result.Tools { seen[tool.Name] = true @@ -655,7 +641,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t if tool.Name == "session_refresh" { require.Empty(t, properties, "session_refresh must remain zero-argument") } - if tool.Name == "keg_search" { + if strings.HasPrefix(tool.Name, "explore_") { require.Contains(t, properties, "query") required, _ := schema["required"].([]any) require.Contains(t, required, "query") diff --git a/pkg/mcp/tool_allowlist.go b/pkg/mcp/tool_allowlist.go index e5b27c4f..be2c4e34 100644 --- a/pkg/mcp/tool_allowlist.go +++ b/pkg/mcp/tool_allowlist.go @@ -12,8 +12,9 @@ import ( // they carry no KEG authority. orient and guide are how a session learns what // it may do; session_info reports the identity and authority it already acts // with; session_refresh retries a failed flight activation and cannot change -// a working one. -var AlwaysAvailableTools = []string{"orient", "guide", "session_info", "session_refresh"} +// a working one; namespace_list and invitation_list report the caller's own +// memberships and pending invitations. +var AlwaysAvailableTools = []string{"orient", "guide", "session_info", "session_refresh", "namespace_list", "invitation_list"} // ToolAllowlist limits a server to an agent's tools. The zero value (and a // nil *ToolAllowlist) admits every tool. diff --git a/pkg/mcp/tool_categories_test.go b/pkg/mcp/tool_categories_test.go new file mode 100644 index 00000000..e849ae4f --- /dev/null +++ b/pkg/mcp/tool_categories_test.go @@ -0,0 +1,37 @@ +package mcp_test + +import ( + "strings" + "testing" + + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +// Every tool tells the agent which job it does before anything else, and +// only the public directory reaches outside the caller's own memory. +func TestToolsDeclareTheirCategory(t *testing.T) { + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{ + AgentProvider: &fakeAgents{}, + NamespaceProvider: &fakeNamespaces{}, + ExploreProvider: &fakeExplore{}, + }) + listed, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.NotEmpty(t, listed.Tools) + + for _, tool := range listed.Tools { + category, ok := tapper.ToolCategoryOf(tool.Name) + require.Truef(t, ok, "%s has no tool category", tool.Name) + require.Truef(t, strings.HasPrefix(tool.Description, string(category)+": "), + "%s description must open with %q: %s", tool.Name, string(category)+": ", tool.Description) + + openWorld := tool.Annotations != nil && tool.Annotations.OpenWorldHint != nil && *tool.Annotations.OpenWorldHint + require.Equalf(t, category == tapper.ToolCategoryExplore, openWorld, + "%s: only Explore tools reach outside the caller's memory", tool.Name) + if strings.HasSuffix(tool.Name, "_search") { + require.Equal(t, "node_search", tool.Name, "searching is for note content and the public directory; private resources are listed") + } + } +} diff --git a/pkg/mcp/tools_agent.go b/pkg/mcp/tools_agent.go index a9c01cf7..9c32df77 100644 --- a/pkg/mcp/tools_agent.go +++ b/pkg/mcp/tools_agent.go @@ -49,8 +49,8 @@ type agentDeleteInput struct { func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_list", - Description: "List Hub agents (model, description, tool allowlist, flight) in a namespace, or in every namespace you belong to", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Manage: List Hub agents (model, description, tool allowlist, flight) in a namespace, or in every namespace you belong to", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := agents.ListAgents(ctx, in.Namespace) if err != nil { @@ -67,8 +67,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_read", - Description: "Read one Hub agent: model, description, instructions, tool allowlist (groups and names, plus effective_tools expanded), and its memory flight", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Manage: Read one Hub agent: model, description, instructions, tool allowlist (groups and names, plus effective_tools expanded), and its memory flight", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentReadInput) (*sdkmcp.CallToolResult, any, error) { agent, err := agents.GetAgent(ctx, in.Agent) if err != nil { @@ -79,8 +79,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_create", - Description: "Create a Hub agent in a namespace you own or administer. Its tools say what it may do; its flight is the memory it works in.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Create a Hub agent in a namespace you own or administer. Its tools say what it may do; its flight is the memory it works in.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentCreateInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil @@ -97,8 +97,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_edit", - Description: "Edit a Hub agent in a namespace you own or administer; omitted fields keep their current values. An edit applies to open sessions running the agent on their next call.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Edit a Hub agent in a namespace you own or administer; omitted fields keep their current values. An edit applies to open sessions running the agent on their next call.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentEditInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil @@ -115,8 +115,8 @@ func registerAgentTools(srv *sdkmcp.Server, defaults KegDefaults, agents AgentPr sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "agent_delete", - Description: "Delete a Hub agent in a namespace you own or administer. Apps and tokens bound to it lose access.", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(true)}, + Description: "Manage: Delete a Hub agent in a namespace you own or administer. Apps and tokens bound to it lose access.", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in agentDeleteInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { return errorResult(err), nil, nil diff --git a/pkg/mcp/tools_agent_test.go b/pkg/mcp/tools_agent_test.go index 6f958d5b..de6ff09c 100644 --- a/pkg/mcp/tools_agent_test.go +++ b/pkg/mcp/tools_agent_test.go @@ -56,14 +56,17 @@ func (f *fakeAgents) DeleteAgent(_ context.Context, ref string) error { return nil } -type fakeNamespaces struct{ lastQuery string } +type fakeNamespaces struct{} func (f *fakeNamespaces) ListNamespaces(context.Context) ([]tapper.HubNamespace, error) { return []tapper.HubNamespace{{Name: "me", Kind: "user", Role: "owner"}}, nil } -func (f *fakeNamespaces) SearchNamespaces(_ context.Context, query string) (tapper.NamespaceSearchResult, error) { - f.lastQuery = query - return tapper.NamespaceSearchResult{Namespaces: []tapper.NamespaceMatch{{Name: "acme", Kind: "org", DisplayName: "Acme"}}}, nil + +type fakeExplore struct{ lastKind tapper.ExploreKind } + +func (f *fakeExplore) Explore(_ context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + f.lastKind = kind + return tapper.ExploreResult{Kind: kind, Results: []tapper.ExploreRow{{Ref: "@acme", Name: "Acme", Role: "member"}}}, nil } func callTool(t *testing.T, ctx context.Context, session *sdkmcp.ClientSession, name string, args map[string]any) *sdkmcp.CallToolResult { @@ -106,15 +109,15 @@ func TestAgentTools_CRUD(t *testing.T) { require.Empty(t, agents.byRef) } -func TestNamespaceTools_ListAndSearch(t *testing.T) { - namespaces := &fakeNamespaces{} - session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{AgentProvider: &fakeAgents{byRef: map[string]tapper.HubAgent{}}, NamespaceProvider: namespaces}) +func TestNamespaceListAndExplorePeople(t *testing.T) { + explore := &fakeExplore{} + session, ctx := newTestSessionWithOpts(t, mcp.ServerOptions{AgentProvider: &fakeAgents{byRef: map[string]tapper.HubAgent{}}, NamespaceProvider: &fakeNamespaces{}, ExploreProvider: explore}) - res := callTool(t, ctx, session, "namespace_search", map[string]any{"query": "ac"}) + res := callTool(t, ctx, session, "explore_people", map[string]any{"query": "ac"}) require.False(t, res.IsError) - require.Equal(t, "ac", namespaces.lastQuery) - found := wirePayload(t, res, "structured")["namespaces"].([]any) - require.Equal(t, "acme", found[0].(map[string]any)["name"]) + require.Equal(t, tapper.ExplorePeople, explore.lastKind) + found := wirePayload(t, res, "structured")["results"].([]any) + require.Equal(t, "@acme", found[0].(map[string]any)["ref"]) res = callTool(t, ctx, session, "namespace_list", nil) require.False(t, res.IsError) diff --git a/pkg/mcp/tools_auth.go b/pkg/mcp/tools_auth.go index 26dac506..b1060c0b 100644 --- a/pkg/mcp/tools_auth.go +++ b/pkg/mcp/tools_auth.go @@ -21,8 +21,8 @@ type authInfoOutput struct { func registerAuthInfoTool(srv *sdkmcp.Server, _ KegDefaults, identities IdentityProvider, kegs KegDiscoveryProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "session_info", - Description: "Report authenticated hub identities and exact pinned-root KEG authority without exposing credentials or private account data. Use keg_list for live flight-graph discovery and keg_search for identity-accessible KEGs outside that graph", - Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}, + Description: "Session: Report authenticated hub identities and exact pinned-root KEG authority without exposing credentials or private account data. Use keg_list for the KEGs this session can work in and orient for the flight and its instructions", + Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ authInfoInput) (*sdkmcp.CallToolResult, any, error) { found, err := identities.Identities(ctx) if err != nil { diff --git a/pkg/mcp/tools_doctor.go b/pkg/mcp/tools_doctor.go index b6653254..38be343a 100644 --- a/pkg/mcp/tools_doctor.go +++ b/pkg/mcp/tools_doctor.go @@ -23,7 +23,7 @@ type doctorInput struct { func registerDoctor(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_check", - Description: "Check KEG health and report issues", + Description: "Manage: Check KEG health and report issues", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_explore.go b/pkg/mcp/tools_explore.go new file mode 100644 index 00000000..2c95a235 --- /dev/null +++ b/pkg/mcp/tools_explore.go @@ -0,0 +1,100 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// ExploreProvider searches the hub's public directory. It never sees the +// caller's private KEGs or flights: those are listed by keg_list and +// flight_list, never searched. +type ExploreProvider interface { + // Explore returns a bounded page of public matches of one kind. + Explore(ctx context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) +} + +type localExploreProvider struct{ tap *tapper.Tap } + +func (p localExploreProvider) Explore(ctx context.Context, kind tapper.ExploreKind, query string) (tapper.ExploreResult, error) { + opts := tapper.ExploreOptions{Query: query} + switch kind { + case tapper.ExploreKegs: + return p.tap.ExploreKegs(ctx, opts) + case tapper.ExploreFlights: + return p.tap.ExploreFlights(ctx, opts) + case tapper.ExplorePeople: + return p.tap.ExplorePeople(ctx, opts) + } + return tapper.ExploreResult{}, fmt.Errorf("%w: unknown explore kind %q", keg.ErrInvalid, kind) +} + +type exploreInput struct { + Query string `json:"query" jsonschema:"literal text to match against public names, references, and descriptions"` +} + +// registerExploreTools exposes the public directory, at parity with +// `tap explore kegs|flights|people`. These are the only search tools over +// resources rather than note content; everything the caller already holds is +// listed instead. +func registerExploreTools(srv *sdkmcp.Server, explore ExploreProvider) { + tools := []struct { + name string + kind tapper.ExploreKind + desc string + }{ + { + name: "explore_kegs", kind: tapper.ExploreKegs, + desc: "Explore: search the hub's public KEGs by reference, title, or description. " + + "Discovery only: a result grants no access, and a KEG outside the flight still needs a flight that covers it. " + + "Your own KEGs come from keg_list; notes come from node_search, never from here.", + }, + { + name: "explore_flights", kind: tapper.ExploreFlights, + desc: "Explore: search the hub's public flights by reference, title, or description. " + + "Discovery only: returns metadata, never instructions, and grants no access. Your own flights come from flight_list.", + }, + { + name: "explore_people", kind: tapper.ExplorePeople, + desc: "Explore: search the people and org namespaces on the hub by name or display name. " + + "Discovery only: role is set where you already belong. Your own namespaces come from namespace_list.", + }, + } + for _, tool := range tools { + kind := tool.kind + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: tool.name, + Description: tool.desc, + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(true), + }, + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in exploreInput) (*sdkmcp.CallToolResult, any, error) { + query := strings.TrimSpace(in.Query) + if query == "" { + return errorResult(fmt.Errorf("%w: query must not be empty; list your own resources instead", keg.ErrInvalid)), nil, nil + } + found, err := explore.Explore(ctx, kind, query) + if err != nil { + return errorResult(err), nil, nil + } + lines := make([]string, 0, len(found.Results)+1) + if found.Truncated { + lines = append(lines, "Results truncated; refine the query.") + } + for _, row := range found.Results { + lines = append(lines, strings.Join([]string{ + row.Ref, tsvField(row.Name), tsvField(row.Description), row.Role, + }, "\t")) + } + res := linesResult(lines) + res.StructuredContent = found + return res, nil, nil + }) + } +} diff --git a/pkg/mcp/tools_files.go b/pkg/mcp/tools_files.go index e9e05406..05bb9566 100644 --- a/pkg/mcp/tools_files.go +++ b/pkg/mcp/tools_files.go @@ -53,7 +53,7 @@ type listFilesInput struct { func registerListFiles(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_list", - Description: "List file attachments for a node", + Description: "Memory: List file attachments for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -84,7 +84,7 @@ type listImagesInput struct { func registerListImages(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "image_list", - Description: "List image attachments for a node", + Description: "Memory: List image attachments for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -141,7 +141,7 @@ type deleteFileInput struct { func registerDeleteFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_delete", - Description: "Delete a file attachment from a node", + Description: "Memory: Delete a file attachment from a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -175,7 +175,7 @@ type deleteImageInput struct { func registerDeleteImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "image_delete", - Description: "Delete an image attachment from a node", + Description: "Memory: Delete an image attachment from a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -225,7 +225,7 @@ type localUploadFileInput struct { func registerUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadFileTool( - "Upload a file attachment to a node from raw bytes, a data URI, or an embedded resource", + "Memory: Upload a file attachment to a node from raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in uploadFileInput) (*sdkmcp.CallToolResult, any, error) { return handleFileUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourceURI: in.SourceURI, @@ -237,7 +237,7 @@ func registerUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault func registerLocalUploadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadFileTool( - "Upload a file attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", + "Memory: Upload a file attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localUploadFileInput) (*sdkmcp.CallToolResult, any, error) { return handleFileUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourcePath: in.SourcePath, @@ -318,7 +318,7 @@ type downloadFileInput struct { func registerDownloadFile(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "file_download", - Description: "Download a file attachment from a node to a local file path", + Description: "Memory: Download a file attachment from a node to a local file path", Annotations: &sdkmcp.ToolAnnotations{ OpenWorldHint: boolPtr(false), }, @@ -367,7 +367,7 @@ type localUploadImageInput struct { func registerUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadImageTool( - "Upload an image attachment to a node from raw bytes, a data URI, or an embedded resource", + "Memory: Upload an image attachment to a node from raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in uploadImageInput) (*sdkmcp.CallToolResult, any, error) { return handleImageUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourceURI: in.SourceURI, @@ -379,7 +379,7 @@ func registerUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaul func registerLocalUploadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, uploadImageTool( - "Upload an image attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", + "Memory: Upload an image attachment to a node from a local path, raw bytes, a data URI, or an embedded resource", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localUploadImageInput) (*sdkmcp.CallToolResult, any, error) { return handleImageUpload(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename, uploadSourceInput{ SourcePath: in.SourcePath, @@ -445,7 +445,7 @@ type localDownloadImageInput struct { func registerDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, downloadImageTool( - "Return an image attachment from a node as MCP image content", + "Memory: Return an image attachment from a node as MCP image content", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in downloadImageInput) (*sdkmcp.CallToolResult, any, error) { return readImageContent(ctx, tap, defaults, in.Keg, in.NodeID, in.Filename) }) @@ -453,7 +453,7 @@ func registerDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefa func registerLocalDownloadImage(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, downloadImageTool( - "Download an image attachment from a node, either to a local file path or as MCP image content", + "Memory: Download an image attachment from a node, either to a local file path or as MCP image content", ), func(ctx context.Context, req *sdkmcp.CallToolRequest, in localDownloadImageInput) (*sdkmcp.CallToolResult, any, error) { dest := strings.TrimSpace(in.DestPath) if dest == "" { diff --git a/pkg/mcp/tools_flight.go b/pkg/mcp/tools_flight.go index 13a45ccd..a820e1df 100644 --- a/pkg/mcp/tools_flight.go +++ b/pkg/mcp/tools_flight.go @@ -22,7 +22,7 @@ type flightCreateInput struct { Description string `json:"description,omitempty" jsonschema:"short description, separate from instructions"` Visibility string `json:"visibility,omitempty" jsonschema:"flight visibility: private (default) or public"` Instructions string `json:"instructions,omitempty" jsonschema:"markdown instructions"` - Cover []string `json:"cover,omitempty" jsonschema:"covered kegs with role caps, e.g. @ns/keg=viewer, @ns/keg=editor, or @ns/keg=admin (bare entries default to viewer)"` + Cover []string `json:"cover,omitempty" jsonschema:"covered kegs with role caps, e.g. @ns/keg=viewer, @ns/keg=editor, or @ns/keg=admin (bare entries default to viewer). A covered keg also covers the kegs its notes link to, to depth 2; @ns/keg=none blocks a keg however it is reached"` Subflights []string `json:"subflights,omitempty" jsonschema:"ordered child flight references; bare +slug references use this flight's namespace"` } @@ -32,7 +32,7 @@ type flightEditInput struct { Description *string `json:"description,omitempty" jsonschema:"short description, separate from instructions"` Visibility *string `json:"visibility,omitempty" jsonschema:"new visibility: private or public; omit to keep current"` Instructions *string `json:"instructions,omitempty" jsonschema:"new markdown instructions; omit to keep the current instructions"` - Cover []string `json:"cover,omitempty" jsonschema:"replacement declared cover, e.g. @ns/keg=viewer; entries use default depth 2. Custom depth cannot be set here. Omit to preserve all current cover entries and depths; [] clears"` + Cover []string `json:"cover,omitempty" jsonschema:"replacement declared cover, e.g. @ns/keg=viewer, or @ns/keg=none to block a keg its notes link to; entries use default depth 2. Custom depth cannot be set here. Omit to preserve all current cover entries and depths; [] clears"` Subflights []string `json:"subflights,omitempty" jsonschema:"replacement ordered child flight references; omit to keep current"` ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by flight_read"` } @@ -48,10 +48,9 @@ type flightDeleteInput struct { // `flight edit`: agents cannot open editors, so the partial-edit tool // (omitted fields keep their current values) remains the MCP surface. func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights FlightProvider) { - registerFlightSearch(srv, flights) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_list", - Description: "List available flights (keg restrictions + agent instructions)", + Description: "Memory: List available flights (keg restrictions + agent instructions)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -66,7 +65,7 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_read", - Description: "Inspect a permission-filtered flight manifest, including declared cover roles, depth, subflights, instructions, and hash. Inspection never activates a session or selects call authority. Cover is declared cover, not effective authority; use orient with flight for current authority. Pass hash as expected_hash to flight_edit or flight_delete.", + Description: "Memory: Inspect a permission-filtered flight manifest, including declared cover roles, depth, subflights, instructions, and hash. Inspection never activates a session or selects call authority. Cover is declared cover, not effective authority; use orient with flight for current authority. Pass hash as expected_hash to flight_edit or flight_delete.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -83,11 +82,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_create", - Description: "Create a Hub-backed flight (cover roles + agent instructions)", + Description: "Manage: Create a Hub-backed flight (cover roles + agent instructions)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightCreateInput) (*sdkmcp.CallToolResult, any, error) { cover, err := tapper.ParseFlightCoverSpecs(in.Cover) @@ -116,11 +115,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_edit", - Description: "Call flight_read first, then edit a Hub-backed flight using its manifest hash as expected_hash; omitted fields keep their current values. On conflict, merge into the returned current flight (or refetch with flight_read) and retry with the returned current hash.", + Description: "Manage: Call flight_read first, then edit a Hub-backed flight using its manifest hash as expected_hash; omitted fields keep their current values. On conflict, merge into the returned current flight (or refetch with flight_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(true), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightEditInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { @@ -163,11 +162,11 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_delete", - Description: "Call flight_read first, then delete a Hub-backed flight using its manifest hash as expected_hash. On conflict, refetch with flight_read and retry with the returned current hash.", + Description: "Manage: Call flight_read first, then delete a Hub-backed flight using its manifest hash as expected_hash. On conflict, refetch with flight_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(true), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightDeleteInput) (*sdkmcp.CallToolResult, any, error) { if err := defaults.gate.authorizeMutation(ctx); err != nil { diff --git a/pkg/mcp/tools_flight_search.go b/pkg/mcp/tools_flight_search.go deleted file mode 100644 index de400387..00000000 --- a/pkg/mcp/tools_flight_search.go +++ /dev/null @@ -1,90 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "sort" - "strings" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// FlightCatalogProvider supplies an identity-filtered catalog without per-resource reads. -type FlightCatalogProvider interface { - // FlightCatalog returns readable flights from one catalog projection. - FlightCatalog(context.Context) ([]*tapper.Flight, error) -} - -// FlightSearchRow is descriptive metadata and confers no authority. -type FlightSearchRow struct { - Ref string `json:"ref"` - Title string `json:"title"` - Description string `json:"description"` -} - -// FlightSearchResult is a bounded deterministic metadata result. -type FlightSearchResult struct { - Flights []FlightSearchRow `json:"flights"` - Truncated bool `json:"truncated"` -} - -// SearchIdentityFlights matches a literal query over readable metadata. -func SearchIdentityFlights(rows []*tapper.Flight, query string) FlightSearchResult { - out := FlightSearchResult{Flights: []FlightSearchRow{}} - query = strings.ToLower(strings.TrimSpace(query)) - if query == "" { - return out - } - seen := map[string]bool{} - for _, f := range rows { - if f == nil || seen[f.Name] { - continue - } - seen[f.Name] = true - if !strings.Contains(strings.ToLower(f.Name+"\n"+f.Title+"\n"+f.Description), query) { - continue - } - title := f.Title - if strings.TrimSpace(title) == "" { - title = f.Name - } - out.Flights = append(out.Flights, FlightSearchRow{Ref: f.Name, Title: title, Description: f.Description}) - } - sort.Slice(out.Flights, func(i, j int) bool { return out.Flights[i].Ref < out.Flights[j].Ref }) - if len(out.Flights) > 50 { - out.Truncated = true - out.Flights = out.Flights[:50] - } - return out -} -func registerFlightSearch(srv *sdkmcp.Server, flights FlightProvider) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "flight_search", Description: "Search readable flight reference, title, and description by literal query. Returns at most 50 deterministic metadata results, never instructions. Refine the query if truncated=true; there is no cursor. Results confer no access.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in kegSearchInput) (*sdkmcp.CallToolResult, any, error) { - if strings.TrimSpace(in.Query) == "" { - return errorResult(fmt.Errorf("%w: query must not be empty", keg.ErrInvalid)), nil, nil - } - provider, ok := flights.(FlightCatalogProvider) - if !ok { - return errorResult(fmt.Errorf("%w: flight catalog unavailable", keg.ErrNotSupported)), nil, nil - } - rows, err := provider.FlightCatalog(ctx) - if err != nil { - return errorResult(err), nil, nil - } - out := SearchIdentityFlights(rows, in.Query) - lines := []string{} - for _, row := range out.Flights { - lines = append(lines, tsvField(row.Title)+"\t"+row.Ref+"\t"+tsvField(row.Description)) - } - if out.Truncated { - lines = append(lines, "Results truncated to 50 flights; refine the query.") - } - res := linesResult(lines) - res.StructuredContent = out - return res, nil, nil - }) -} -func (p localFlightProvider) FlightCatalog(ctx context.Context) ([]*tapper.Flight, error) { - return p.tap.FlightService.FlightCatalog(ctx, "", nil) -} diff --git a/pkg/mcp/tools_index.go b/pkg/mcp/tools_index.go index 1cdb7ac0..98c969ad 100644 --- a/pkg/mcp/tools_index.go +++ b/pkg/mcp/tools_index.go @@ -24,7 +24,7 @@ type indexInput struct { func registerIndex(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_rebuild", - Description: "Rebuild all KEG indexes from scratch (nodes, tags, links, backlinks)", + Description: "Manage: Rebuild all KEG indexes from scratch (nodes, tags, links, backlinks)", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), IdempotentHint: true, @@ -51,7 +51,7 @@ type listIndexesInput struct { func registerListIndexes(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_list", - Description: "List available index files for a KEG", + Description: "Memory: List available index files for a KEG", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -78,7 +78,7 @@ type indexCatInput struct { func registerIndexCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "index_read", - Description: "Read the raw contents of a KEG index file", + Description: "Memory: Read the raw contents of a KEG index file", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_keg.go b/pkg/mcp/tools_keg.go index 331a8e82..497962ea 100644 --- a/pkg/mcp/tools_keg.go +++ b/pkg/mcp/tools_keg.go @@ -15,20 +15,18 @@ import ( type kegListInput struct{} type kegListRow struct { - Ref string `json:"ref"` - Role string `json:"role"` - Flights []string `json:"flights"` - KegVersion string `json:"kegv,omitempty"` + Ref string `json:"ref"` + Role string `json:"role"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Flights []string `json:"flights"` + KegVersion string `json:"kegv,omitempty"` } type kegListOutput struct { Kegs []kegListRow `json:"kegs"` } -type kegSearchInput struct { - Query string `json:"query" jsonschema:"required,non-empty case-insensitive literal query matched against canonical ref, title, and description"` -} - 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"` @@ -41,14 +39,14 @@ type kegCreateInput struct { // with normal namespace membership; whether it holds keg_create at all is its // agent's tool list, gated by the host. Transport-specific hub selection // is intentionally absent from the agent surface. -func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider, search KegSearchProvider) { +func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider) { registerKegDelete(srv, defaults, kegs) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_list", - Description: "Discover canonical KEGs and granting-flight provenance. Without a configured root, returns identity-accessible KEGs at their real roles. With a pinned root, omission aggregates its accessible transitive graph for discovery; a discovered child-only KEG still requires that child as flight on operational calls. Supplying flight returns exactly that flight projection", + Description: "Memory: List every KEG this session can work in, with role, title, description, and granting-flight provenance. The list is complete, so read it rather than searching; explore_kegs searches public KEGs you do not hold. Without a configured root, returns identity-accessible KEGs at their real roles. With a pinned root, omission aggregates its accessible transitive graph for discovery; a discovered child-only KEG still requires that child as flight on operational calls. Supplying flight returns exactly that flight projection", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegListInput) (*sdkmcp.CallToolResult, any, error) { if HasSessionOrientation(ctx) { @@ -58,12 +56,16 @@ 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, 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) + out.Kegs = append(out.Kegs, kegListRow{ + Ref: row.Ref, Role: effective, Title: row.Title, Description: row.Description, + Flights: flights, KegVersion: row.KegVersion, + }) + // Title and description trail so a row without them keeps the + // ref, role, and flights columns unpadded. + lines = append(lines, strings.TrimRight(strings.Join([]string{ + row.Ref, effective, strings.Join(flights, ","), + tsvField(tapper.KegTitleWithVersion(row.Title, row.KegVersion)), tsvField(row.Description), + }, "\t"), "\t")) } res := linesResult(lines) res.StructuredContent = out @@ -87,47 +89,14 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover return res, nil, nil }) - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "keg_search", - Description: "Search identity-accessible KEG metadata on the connection-pinned Hub. Search results never grant access: no-flight calls may operate at the returned identity role, while a real-flight call must also cover the KEG", - Annotations: &sdkmcp.ToolAnnotations{ - ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), - }, - }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegSearchInput) (*sdkmcp.CallToolResult, any, error) { - query := strings.TrimSpace(in.Query) - if query == "" { - return errorResult(fmt.Errorf("%w: query must not be empty", keg.ErrInvalid)), nil, nil - } - found, err := search.SearchKegs(ctx, query) - if err != nil { - return errorResult(err), nil, nil - } - lines := make([]string, 0, len(found.Warnings)+len(found.Kegs)) - if found.Truncated { - lines = append(lines, "Results truncated to 50 KEGs; refine the query.") - } - for _, warning := range found.Warnings { - lines = append(lines, "Warning: "+tsvField(warning)) - } - for _, row := range found.Kegs { - lines = append(lines, strings.Join([]string{ - row.Ref, row.Role, tsvField(tapper.KegTitleWithVersion(row.Title, row.KegVersion)), tsvField(row.Description), row.Visibility, row.Source, - }, "\t")) - } - res := linesResult(lines) - res.StructuredContent = found - return res, nil, nil - }) - sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_create", - Description: "Create a new KEG in a namespace you belong to. Creating a KEG " + + Description: "Manage: Create a new KEG in a namespace you belong to. Creating a KEG " + "never adds it to a flight's cover.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegCreateInput) (*sdkmcp.CallToolResult, any, error) { // The gate refuses this tool before dispatch; the check is repeated here @@ -208,7 +177,7 @@ type kegDeleteInput struct { } func registerKegDelete(srv *sdkmcp.Server, defaults KegDefaults, provider KegDiscoveryProvider) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "keg_delete", Description: "Permanently delete an empty or populated KEG and all its data, including snapshots. Requires identity admin permission; a selected flight also requires effective admin cover. No expected_hash: a settings hash does not cover a whole KEG.", Annotations: &sdkmcp.ToolAnnotations{DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(true)}}, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegDeleteInput) (*sdkmcp.CallToolResult, any, error) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "keg_delete", Description: "Manage: Permanently delete an empty or populated KEG and all its data, including snapshots. Requires identity admin permission; a selected flight also requires effective admin cover. No expected_hash: a settings hash does not cover a whole KEG.", Annotations: &sdkmcp.ToolAnnotations{DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegDeleteInput) (*sdkmcp.CallToolResult, any, error) { ns, alias, err := tapper.ParseCanonicalKegRef(in.Keg) if err != nil { return errorResult(fmt.Errorf("keg must be an explicit canonical @namespace/keg reference: %w", err)), nil, nil diff --git a/pkg/mcp/tools_lock.go b/pkg/mcp/tools_lock.go index b083096b..437400c7 100644 --- a/pkg/mcp/tools_lock.go +++ b/pkg/mcp/tools_lock.go @@ -27,7 +27,7 @@ type lockAcquireInput struct { func registerLockAcquire(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_acquire", - Description: "Acquire a cross-process lock on a node and return the token", + Description: "Memory: Acquire a cross-process lock on a node and return the token", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -56,7 +56,7 @@ type lockReleaseInput struct { func registerLockRelease(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_release", - Description: "Release a cross-process lock on a node", + Description: "Memory: Release a cross-process lock on a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -84,7 +84,7 @@ type lockStatusInput struct { func registerLockStatus(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_status", - Description: "Check the lock state of a node", + Description: "Memory: Check the lock state of a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -120,7 +120,7 @@ type lockForceReleaseInput struct { func registerLockForceRelease(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "lock_force_release", - Description: "Unconditionally remove a lock on a node", + Description: "Manage: Unconditionally remove a lock on a node", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_merge.go b/pkg/mcp/tools_merge.go new file mode 100644 index 00000000..4e24c6ff --- /dev/null +++ b/pkg/mcp/tools_merge.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "context" + "fmt" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- node_merge --- + +type mergeInput struct { + SourceID string `json:"source_id" jsonschema:"id of the node merged away; it goes to the trash"` + ExpectedSourceHash string `json:"expected_source_hash" jsonschema:"the source's combined hash from node_read (not content_hash or meta_hash)"` + TargetID string `json:"target_id" jsonschema:"id of the node that survives and takes the merged body"` + Content *string `json:"content,omitempty" jsonschema:"the target's merged markdown body, replacing its content entirely. Must not start with a YAML frontmatter block. Send content or patch."` + Patch *string `json:"patch,omitempty" jsonschema:"unified diff against the target's current content, an alternative to content when the target already holds most of the merged body"` + Meta *string `json:"meta,omitempty" jsonschema:"the target's replacement metadata as YAML, for combining tags and fields; omit to keep it"` + ExpectedContentHash string `json:"expected_content_hash" jsonschema:"the target's content_hash from node_read"` + ExpectedMetaHash string `json:"expected_meta_hash,omitempty" jsonschema:"the target's meta_hash from node_read; required when meta is supplied"` + Schema string `json:"schema,omitempty" jsonschema:"schema for the target, as for node_edit: required on a strict keg when meta is supplied"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +func registerMerge(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "node_merge", + Description: "Memory: Merge a duplicate node into another as one atomic write, to deduplicate a keg. " + + "Call node_read on both first. The target survives: it takes the merged body you write (content, or a patch against its current content) and optionally merged meta. " + + "Every ../SOURCE link in the keg, the target's own included, is repointed at the target, and the source goes to the trash, recorded as merged into the target, where trash_restore can bring it back. " + + "Guard the source with its combined hash as expected_source_hash, and the target with its content_hash (and meta_hash when sending meta). " + + "The result returns the target's new hashes and the other nodes whose links moved.", + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in mergeInput) (*sdkmcp.CallToolResult, any, error) { + ctx = keg.WithValidationActor(ctx, keg.ValidationActorAgent) + target := tapper.BatchEditItem{NodeID: in.TargetID, Schema: in.Schema, ExpectedContentHash: in.ExpectedContentHash, ExpectedMetaHash: in.ExpectedMetaHash} + if in.Content != nil { + target.Content, target.HasContent = *in.Content, true + } + if in.Patch != nil { + target.ContentPatch, target.HasContentPatch = *in.Patch, true + } + if in.Meta != nil { + target.Meta, target.HasMeta = *in.Meta, true + } + result, err := tap.Merge(ctx, tapper.MergeOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + SourceID: in.SourceID, + ExpectedSourceHash: in.ExpectedSourceHash, + Target: target, + }) + if err != nil { + return errorResult(err), nil, nil + } + rewritten := make([]string, len(result.Rewritten)) + for i, id := range result.Rewritten { + rewritten[i] = id.Path() + } + res := textResult(fmt.Sprintf("merged %s into %s; %d other node(s) relinked", in.SourceID, result.Target.ID.Path(), len(rewritten))) + res.StructuredContent = map[string]any{ + "target": nodeUpdateOutputs([]keg.NodeUpdateResult{result.Target})[0], + "rewritten": rewritten, + } + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_namespace.go b/pkg/mcp/tools_namespace.go index 21373fe6..12758e01 100644 --- a/pkg/mcp/tools_namespace.go +++ b/pkg/mcp/tools_namespace.go @@ -11,10 +11,6 @@ import ( type namespaceListInput struct{} -type namespaceSearchInput struct { - Query string `json:"query,omitempty" jsonschema:"text to match against namespace names and display names; empty browses"` -} - // InvitationLister is implemented by namespace providers that can list the // caller's pending invitations. It is optional so a provider without it still // satisfies NamespaceProvider; invitation_list registers only when present. @@ -24,7 +20,7 @@ type InvitationLister interface { } // registerNamespaceTools exposes namespace discovery over MCP, at parity with -// `tap namespace list` and `tap namespace search`. Discovery never grants +// `tap namespace list`. The hub-wide people directory is explore_people. Listing never grants // authority. Member rosters and role changes stay UI-only, so those tools are // intentionally not registered. Pending invitations are listed read-only: // accepting or declining access is the account holder's own decision, made in @@ -33,10 +29,10 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { if lister, ok := namespaces.(InvitationLister); ok { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "invitation_list", - Description: "List pending invitations addressed to you: org memberships and keg grants that take effect only if you accept them. Read-only; accepting or declining is your user's decision, made in the Hub UI or with `tap invitation`, so ask them rather than acting.", + Description: "Session: List pending invitations addressed to you: org memberships and keg grants that take effect only if you accept them. Read-only; accepting or declining is your user's decision, made in the Hub UI or with `tap invitation`, so ask them rather than acting.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ namespaceListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := lister.ListInvitations(ctx) @@ -54,10 +50,10 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { } sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "namespace_list", - Description: "List the namespaces you belong to, with your role in each", + Description: "Session: List the namespaces you belong to, with your role in each", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ namespaceListInput) (*sdkmcp.CallToolResult, any, error) { rows, err := namespaces.ListNamespaces(ctx) @@ -72,28 +68,4 @@ func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { res.StructuredContent = map[string]any{"namespaces": rows} return res, nil, nil }) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "namespace_search", - Description: "Search the people and org namespaces visible on the Hub, not only your own. Results are discovery only and never grant access; role is set where you are a member. Use keg_search and flight_search to find their kegs and flights.", - Annotations: &sdkmcp.ToolAnnotations{ - ReadOnlyHint: true, - OpenWorldHint: boolPtr(true), - }, - }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in namespaceSearchInput) (*sdkmcp.CallToolResult, any, error) { - found, err := namespaces.SearchNamespaces(ctx, in.Query) - if err != nil { - return errorResult(err), nil, nil - } - lines := make([]string, 0, len(found.Namespaces)+1) - if found.Truncated { - lines = append(lines, "Results truncated; refine the query.") - } - for _, ns := range found.Namespaces { - lines = append(lines, fmt.Sprintf("@%s\t%s\t%s\t%s", ns.Name, ns.Kind, tsvField(ns.DisplayName), ns.Role)) - } - res := linesResult(lines) - res.StructuredContent = found - return res, nil, nil - }) } diff --git a/pkg/mcp/tools_orient.go b/pkg/mcp/tools_orient.go index d2d27740..110b2621 100644 --- a/pkg/mcp/tools_orient.go +++ b/pkg/mcp/tools_orient.go @@ -28,7 +28,7 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { // instructions arrive in this tool's *result*, which lives in the // conversation and is exactly what a clear or compact discards — so the // instruction to come back must live somewhere the reset cannot reach. - Description: "Call this first in every session, before any other tool and before " + + Description: "Session: Call this first in every session, before any other tool and before " + "replying to the user — not only when KEG work is expected. The active flight " + "carries this session's operating instructions, so until you orient you do not " + "know what the session is for or which KEGs you may touch. Returns that flight, " + @@ -39,7 +39,7 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { "not — orient. This tool is read-only and never activates or changes the connection's " + "session state. Every authority-bearing call accepts an optional top-level flight. " + "With no flight, omission uses all identity-authorized KEGs while an explicit value selects any listed real flight exactly. " + - "Orient reveals effective readable KEGs and immediate readable child flights. With no configured root it is search-first; use keg_search and flight_search to discover resources. Call orient with a child reference to reveal the next level. Explicit descendant selection remains limited to the authorized graph. Calls reload live authority independently, so concurrent agents may use different flights without " + + "Orient reveals effective readable KEGs and immediate readable child flights. With no configured root it lists every KEG your identity reaches; keg_list and flight_list are the complete lists, and explore_kegs and explore_flights search the public directory. Call orient with a child reference to reveal the next level. Explicit descendant selection remains limited to the authorized graph. Calls reload live authority independently, so concurrent agents may use different flights without " + "changing shared session state. " + "If an explicitly configured root fails to load, the session fails closed with only recovery tools. " + "When no root is configured, no-flight full access stays pinned until the connection ends; pin a restrictive flight outside MCP and start a new connection.", @@ -63,13 +63,13 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { func registerSessionRefresh(srv *sdkmcp.Server, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "session_refresh", - Description: "Retry MCP session activation after a failed configured flight becomes available. " + + Description: "Session: Retry MCP session activation after a failed configured flight becomes available. " + "Takes no arguments and never changes an already-active connection. A no-flight full-access " + "connection therefore requires a new MCP connection after a restrictive flight is pinned.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(true), + OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ sessionRefreshInput) (*sdkmcp.CallToolResult, any, error) { out, err := defaults.gate.refresh(ctx, sessionIDFromContext(ctx)) @@ -100,7 +100,7 @@ type guideInput struct { } func registerGuide(srv *sdkmcp.Server) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "guide", Description: "Read canonical KEG guidance on demand by topic.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in guideInput) (*sdkmcp.CallToolResult, any, error) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{Name: "guide", Description: "Session: Read canonical KEG guidance on demand by topic.", Annotations: &sdkmcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)}}, func(ctx context.Context, req *sdkmcp.CallToolRequest, in guideInput) (*sdkmcp.CallToolResult, any, error) { text, err := tapper.OrientationGuide(in.Topic) if err != nil { return errorResult(err), nil, nil diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index 1cf8f1f3..478bc894 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -20,6 +20,7 @@ func registerReadTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults registerInfo(srv, tap, defaults) registerKegSettings(srv, tap, defaults) registerStats(srv, tap, defaults) + registerURL(srv, tap, defaults) } // --- node_read --- @@ -86,7 +87,7 @@ func nodeReadSummary(count int) string { func registerCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_read", - Description: "Read KEG nodes using node_ids or query; they are mutually exclusive, and omitting both returns no nodes. The default returns metadata and content together; " + + Description: "Memory: Read KEG nodes using node_ids or query; they are mutually exclusive, and omitting both returns no nodes. The default returns metadata and content together; " + "meta_only returns just the metadata document, which is how you read metadata before " + "editing it. Each result carries content_hash (when content was read) and meta_hash (when " + "metadata was read): pass content_hash as expected_content_hash and meta_hash as " + @@ -138,7 +139,7 @@ type listInput struct { func registerList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_list", - Description: "List KEG nodes, optionally filtered by a query expression", + Description: "Memory: List KEG nodes, optionally filtered by a query expression", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -179,7 +180,7 @@ type grepInput struct { func registerGrep(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ 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.", + Description: "Memory: 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), @@ -219,7 +220,7 @@ type tagsInput struct { func registerTags(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "tag_list", - Description: "List tags or filter nodes by tag expression", + Description: "Memory: List tags or filter nodes by tag expression", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -257,7 +258,7 @@ type backlinksInput struct { func registerBacklinks(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_backlinks", - Description: "List nodes that link to a given node", + Description: "Memory: List nodes that link to a given node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -295,7 +296,7 @@ type linksInput struct { func registerLinks(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_links", - Description: "List outgoing links from a node", + Description: "Memory: List outgoing links from a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -329,7 +330,7 @@ type kegSettingsInput struct { func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings_read", - Description: "Read targeted KEG settings and instructions. Defaults to a minimal summary; set minimal=false for one complete YAML data document and its hash, required by keg_settings_edit. Use keg or 1-100 kegs, never both.", + Description: "Memory: Read targeted KEG settings and instructions. Defaults to a minimal summary; set minimal=false for one complete YAML data document and its hash, required by keg_settings_edit. Use keg or 1-100 kegs, never both.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -392,7 +393,7 @@ type infoInput struct { func registerInfo(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_info", - Description: "Show concise path-free diagnostics for a resolved KEG (canonical ref, flight, description, node count, and capabilities)", + Description: "Memory: Show concise path-free diagnostics for a resolved KEG (canonical ref, flight, description, node count, and capabilities)", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -419,7 +420,7 @@ type statsInput struct { func registerStats(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_stats", - Description: "Show stats (hash, timestamps, access count) for a node", + Description: "Memory: Show stats (hash, timestamps, access count) for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_replace.go b/pkg/mcp/tools_replace.go new file mode 100644 index 00000000..f9062612 --- /dev/null +++ b/pkg/mcp/tools_replace.go @@ -0,0 +1,82 @@ +package mcp + +import ( + "context" + "fmt" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- keg_replace --- + +type replaceInput struct { + Find string `json:"find" jsonschema:"text to find; with regex, a Go (RE2) regular expression"` + Replace string `json:"replace" jsonschema:"replacement text; with regex, $1 and ${name} insert captured groups"` + Regex bool `json:"regex,omitempty" jsonschema:"treat find as a regular expression instead of literal text"` + Query string `json:"query,omitempty" jsonschema:"limit the replace to the nodes this node_list query selects, e.g. a tag; empty means every node"` + IncludeMeta bool `json:"include_meta,omitempty" jsonschema:"replace in each node's metadata YAML as well as its body"` + DryRun bool `json:"dry_run,omitempty" jsonschema:"report what would change without writing"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +type replaceNodeOutput struct { + NodeID string `json:"node_id"` + Matches int `json:"matches"` + Error string `json:"error,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + MetaHash string `json:"meta_hash,omitempty"` + Hash string `json:"hash,omitempty"` +} + +func registerReplace(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "keg_replace", + Description: "Manage: Find and replace text across a keg's nodes in one atomic write, for renames and consistent wording. " + + "Call it with dry_run first: it lists every node that would change and how many matches each has, and writes nothing. " + + "find is literal unless regex is set; query narrows the nodes (node_list syntax); include_meta also rewrites metadata. " + + "A node whose replacement would break any write rule (the keg's schemas, metadata that must parse, a body that may not open with frontmatter) is reported with its error (and no bodies) and left alone while the rest are written; dry_run reports the same errors. " + + fmt.Sprintf("At most %d matching nodes in one call, counting those left alone. ", keg.MaxReplaceNodes) + + fmt.Sprintf("A node with more than %d matches, or that would grow past %d MiB, is left alone with its error; a replace that would grow the changed nodes past %d MiB in all fails. ", keg.MaxReplaceNodeMatches, keg.MaxReplaceNodeBytes>>20, keg.MaxReplaceTotalBytes>>20) + + "Results return each changed node's new hashes.", + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in replaceInput) (*sdkmcp.CallToolResult, any, error) { + ctx = keg.WithValidationActor(ctx, keg.ValidationActorAgent) + result, err := tap.Replace(ctx, tapper.ReplaceOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + ReplaceTextOptions: keg.ReplaceTextOptions{ + Find: in.Find, Replace: in.Replace, Regex: in.Regex, Query: in.Query, IncludeMeta: in.IncludeMeta, DryRun: in.DryRun, + }, + }) + if err != nil { + return errorResult(err), nil, nil + } + nodes := make([]replaceNodeOutput, len(result.Nodes)) + lines := make([]string, 0, len(result.Nodes)+1) + changed, matches := 0, 0 + for i, node := range result.Nodes { + nodes[i] = replaceNodeOutput{NodeID: node.ID.Path(), Matches: node.Matches, Error: node.Error, ContentHash: node.ContentHash, MetaHash: node.MetaHash, Hash: node.Hash} + line := fmt.Sprintf("%s %d match(es)", node.ID.Path(), node.Matches) + if node.Error != "" { + line += " skipped: " + node.Error + } else { + changed++ + matches += node.Matches + } + lines = append(lines, line) + } + verb := "changed" + if in.DryRun { + verb = "would change" + } + lines = append([]string{fmt.Sprintf("%s %d node(s), %d match(es)", verb, changed, matches)}, lines...) + res := linesResult(lines) + res.StructuredContent = map[string]any{"dry_run": in.DryRun, "nodes": nodes} + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_schema.go b/pkg/mcp/tools_schema.go index f4b298e7..dcf42f46 100644 --- a/pkg/mcp/tools_schema.go +++ b/pkg/mcp/tools_schema.go @@ -37,7 +37,7 @@ type schemaListInput struct { func registerSchemaList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_list", - Description: "List the schema type names defined for a keg", + Description: "Memory: List the schema type names defined for a keg", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -66,7 +66,7 @@ type schemaReadInput struct { func registerSchemaRead(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_read", - Description: "Read one schema definition as YAML. The result carries the schema's " + + Description: "Memory: Read one schema definition as YAML. The result carries the schema's " + "hash; pass it back as expected_hash when editing this schema.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, @@ -100,7 +100,7 @@ type schemaCreateInput struct { func registerSchemaCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_create", - Description: "Create a new keg schema from a YAML definition (fails if the type already exists). Requires admin access to the KEG, and admin cover when a flight is selected", + Description: "Manage: Create a new keg schema from a YAML definition (fails if the type already exists). Requires admin access to the KEG, and admin cover when a flight is selected", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -128,7 +128,7 @@ type schemaEditInput struct { func registerSchemaEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_edit", - Description: "Call schema_read first, then replace an existing keg schema with a new YAML definition using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, merge into the returned current schema (or refetch with schema_read) and retry with the returned current hash.", + Description: "Manage: Change how a KEG validates notes; writing a note never needs this, so pick an existing type from schema_list instead. Call schema_read first, then replace an existing keg schema with a new YAML definition using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, merge into the returned current schema (or refetch with schema_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -160,7 +160,7 @@ type schemaDeleteInput struct { func registerSchemaDelete(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_delete", - Description: "Call schema_read first, then delete a keg schema using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, refetch with schema_read and retry with the returned current hash.", + Description: "Manage: Call schema_read first, then delete a keg schema using its hash as expected_hash. Requires admin access to the KEG, and admin cover when a flight is selected. On conflict, refetch with schema_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -187,7 +187,7 @@ type validateInput struct { func registerValidate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_validate", - Description: "Validate nodes against their declared schema type", + Description: "Memory: Validate nodes against their declared schema type", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_search.go b/pkg/mcp/tools_search.go index a63255f5..58112ddf 100644 --- a/pkg/mcp/tools_search.go +++ b/pkg/mcp/tools_search.go @@ -28,7 +28,8 @@ type nodeSearchOutput struct { 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. " + + Description: "Memory: Find notes about a topic: the KEG nodes most relevant to a query, ranked across every KEG this call may read (omit keg), one named keg, or the listed kegs. " + + "This is the only search over note content: explore_kegs finds public KEGs, never notes, and keg_list lists your own 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.", diff --git a/pkg/mcp/tools_search_test.go b/pkg/mcp/tools_search_test.go index 6f044dbe..95088c59 100644 --- a/pkg/mcp/tools_search_test.go +++ b/pkg/mcp/tools_search_test.go @@ -155,7 +155,7 @@ func newFlightSearchSession(t *testing.T, mode string, provider mcp.NodeSearchPr 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, + ExploreProvider: backend, IdentityProvider: backend, NodeSearchProvider: provider, }) return connectFlightSession(t, ctx, server, nil), ctx } diff --git a/pkg/mcp/tools_snapshot.go b/pkg/mcp/tools_snapshot.go index 28589790..f6dddd10 100644 --- a/pkg/mcp/tools_snapshot.go +++ b/pkg/mcp/tools_snapshot.go @@ -27,7 +27,7 @@ type nodeHistoryInput struct { func registerNodeHistory(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_list", - Description: "List snapshot history for a node", + Description: "Memory: List snapshot history for a node", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -76,7 +76,7 @@ type nodeSnapshotOutput struct { func registerNodeSnapshot(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_create", - Description: "Atomically snapshot the current state of 1-100 nodes", + Description: "Memory: Atomically snapshot the current state of 1-100 nodes", InputSchema: boundedMutationInputSchema[nodeSnapshotInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), @@ -116,7 +116,7 @@ type nodeSnapshotViewInput struct { func registerNodeSnapshotView(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_read", - Description: "View read-only content for a previous snapshot revision", + Description: "Memory: View read-only content for a previous snapshot revision", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -146,7 +146,7 @@ type nodeRestoreInput struct { func registerNodeRestore(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "snapshot_restore", - Description: "Restore a node to a previous snapshot revision", + Description: "Memory: Restore a node to a previous snapshot revision", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/mcp/tools_trash.go b/pkg/mcp/tools_trash.go new file mode 100644 index 00000000..28e62fbf --- /dev/null +++ b/pkg/mcp/tools_trash.go @@ -0,0 +1,118 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func registerTrashTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + registerTrashList(srv, tap, defaults) + registerTrashRestore(srv, tap, defaults) +} + +// --- trash_list --- + +type trashListInput struct { + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +type trashEntryOutput struct { + NodeID string `json:"node_id"` + Title string `json:"title"` + Reason string `json:"reason"` + MergedInto string `json:"merged_into,omitempty"` + TrashedAt string `json:"trashed_at"` + ExpiresAt string `json:"expires_at,omitempty"` + // DisplacedFrom is the id the node was trashed at, when an archive + // restore needed it and the entry moved to node_id. + DisplacedFrom string `json:"displaced_from,omitempty"` +} + +func trashEntryOutputs(entries []keg.TrashEntry) []trashEntryOutput { + out := make([]trashEntryOutput, len(entries)) + for i, entry := range entries { + out[i] = trashEntryOutput{NodeID: entry.ID.Path(), Title: entry.Title, Reason: entry.Reason, TrashedAt: entry.TrashedAt.UTC().Format(time.RFC3339)} + if entry.MergedInto != nil { + out[i].MergedInto = entry.MergedInto.Path() + } + if entry.ExpiresAt != nil { + out[i].ExpiresAt = entry.ExpiresAt.UTC().Format(time.RFC3339) + } + if entry.DisplacedFrom != nil { + out[i].DisplacedFrom = entry.DisplacedFrom.Path() + } + } + return out +} + +func registerTrashList(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "trash_list", + Description: "Memory: List a keg's removed nodes, newest first: id, title, why it was removed (manual, or merge with the node it was merged into), when, and when it will be purged for good. " + + "Restore one with trash_restore. A trashed node's id stays reserved, and links to it are left in place until it is purged. " + + "An archive restore that needs a trashed node's id moves the entry to a new id first; displaced_from names the id it was trashed at, and restoring puts it back at its new id.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in trashListInput) (*sdkmcp.CallToolResult, any, error) { + entries, err := tap.TrashList(ctx, resolveKegTarget(ctx, in.Keg, defaults)) + if err != nil { + return errorResult(err), nil, nil + } + if len(entries) == 0 { + res := textResult("trash is empty") + res.StructuredContent = map[string]any{"trash": []trashEntryOutput{}} + return res, nil, nil + } + structured := trashEntryOutputs(entries) + lines := make([]string, len(structured)) + for i, entry := range structured { + line := fmt.Sprintf("%s %s %s", entry.NodeID, entry.TrashedAt, entry.Reason) + if entry.MergedInto != "" { + line += " into " + entry.MergedInto + } + if entry.DisplacedFrom != "" { + line += " (moved from " + entry.DisplacedFrom + ")" + } + if entry.Title != "" { + line += " " + entry.Title + } + lines[i] = line + } + res := linesResult(lines) + res.StructuredContent = map[string]any{"trash": structured} + return res, nil, nil + }) +} + +// --- trash_restore --- + +type trashRestoreInput struct { + NodeIDs []string `json:"node_ids" jsonschema:"1-100 trashed node ids to restore atomically"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +func registerTrashRestore(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "trash_restore", + Description: "Memory: Atomically restore 1-100 trashed nodes (see trash_list) at their own ids, with their content, metadata, history, files, and images. " + + "Links that still name a restored node resolve again. A node merged into another comes back as it was before the merge; links the merge repointed stay on the node it was merged into.", + InputSchema: boundedMutationInputSchema[trashRestoreInput]("node_ids"), + Annotations: &sdkmcp.ToolAnnotations{ + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in trashRestoreInput) (*sdkmcp.CallToolResult, any, error) { + if err := tap.TrashRestore(ctx, tapper.TrashRestoreOptions{KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), NodeIDs: in.NodeIDs}); err != nil { + return errorResult(err), nil, nil + } + return textResult(fmt.Sprintf("restored %d node(s)", len(in.NodeIDs))), nil, nil + }) +} diff --git a/pkg/mcp/tools_url.go b/pkg/mcp/tools_url.go new file mode 100644 index 00000000..1fcc9de2 --- /dev/null +++ b/pkg/mcp/tools_url.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "context" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/tapper" +) + +// --- url --- + +type urlInput struct { + Ref string `json:"ref,omitempty" jsonschema:"what to link: a node id in keg (42), a KEG (keg:alias, keg:~alias, @ns/keg), a note (keg:alias/42, keg:~alias/42, @ns/keg/42), a flight (+slug, @ns/+slug), or a namespace (@ns). Empty links keg itself."` + Keg string `json:"keg,omitempty" jsonschema:"keg a bare node id or keg-local ~alias resolves against (uses default if empty)"` +} + +func registerURL(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "url", + Description: "Memory: Get the Hub web link for a KEG, note, flight, or namespace, to give the reader a clickable link. " + + "Returns url, kind (namespace, keg, node, or flight), and the canonical ref. Building a link never reads the note.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(false), + }, + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in urlInput) (*sdkmcp.CallToolResult, any, error) { + link, err := tap.WebURL(ctx, tapper.WebURLOptions{ + KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + Ref: in.Ref, + }) + if err != nil { + return errorResult(err), nil, nil + } + res := textResult(link.URL) + res.StructuredContent = link + return res, nil, nil + }) +} diff --git a/pkg/mcp/tools_url_test.go b/pkg/mcp/tools_url_test.go new file mode 100644 index 00000000..4ae41058 --- /dev/null +++ b/pkg/mcp/tools_url_test.go @@ -0,0 +1,84 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func newURLSession(t *testing.T, webBaseURL string) (*sdkmcp.ClientSession, context.Context) { + t.Helper() + ctx := context.Background() + rt := newTestSandbox(t).Runtime() + tap := newMemoryTap(t, ctx, rt) + tap.WebBaseURL = webBaseURL + + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}) + serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() + done := make(chan error, 1) + go func() { done <- srv.Run(ctx, serverTransport) }() + t.Cleanup(func() { <-done }) + session, err := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "0.1"}, nil). + Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return session, ctx +} + +func callURL(t *testing.T, session *sdkmcp.ClientSession, ctx context.Context, args map[string]any) (*sdkmcp.CallToolResult, tapper.WebURL) { + t.Helper() + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "url", Arguments: args}) + require.NoError(t, err) + var link tapper.WebURL + if result.StructuredContent != nil { + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &link)) + } + return result, link +} + +func TestURLTool_ReturnsTheNoteLink(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + + result, link := callURL(t, session, ctx, map[string]any{"ref": "1"}) + require.False(t, result.IsError, extractText(t, result)) + require.Equal(t, tapper.WebURL{URL: "https://hub.example/@local/personal/1", Kind: "node", Ref: "keg:@local/personal/1"}, link) + require.Equal(t, "https://hub.example/@local/personal/1", extractText(t, result)) + + _, link = callURL(t, session, ctx, map[string]any{"ref": "@team/+dev"}) + require.Equal(t, tapper.WebURL{URL: "https://hub.example/@team/+dev", Kind: "flight", Ref: "@team/+dev"}, link) + + _, link = callURL(t, session, ctx, map[string]any{}) + require.Equal(t, "keg", link.Kind) + require.Equal(t, "https://hub.example/@local/personal", link.URL) +} + +func TestURLTool_IsReadOnly(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + listed, err := session.ListTools(ctx, nil) + require.NoError(t, err) + for _, tool := range listed.Tools { + if tool.Name == "url" { + require.True(t, tool.Annotations.ReadOnlyHint) + return + } + } + t.Fatal("url tool not registered") +} + +func TestURLTool_ReportsAnUnrecognizedRef(t *testing.T) { + t.Parallel() + session, ctx := newURLSession(t, "https://hub.example") + result, _ := callURL(t, session, ctx, map[string]any{"ref": "Not A Keg!"}) + require.True(t, result.IsError) + require.Contains(t, extractText(t, result), "unrecognized reference") +} diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index d95299ee..c45fb3d3 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -35,6 +35,8 @@ func registerWriteTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault registerEdit(srv, tap, defaults) registerRemove(srv, tap, defaults) registerMove(srv, tap, defaults) + registerMerge(srv, tap, defaults) + registerReplace(srv, tap, defaults) registerKegSettingsEdit(srv, tap, defaults) } @@ -49,7 +51,7 @@ type kegSettingsEditInput struct { func registerKegSettingsEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings_edit", - Description: "Call keg_settings_read with minimal=false first, then replace the complete KEG settings with a validated YAML document using its hash as expected_hash. Requires admin access to the KEG itself, plus admin cover when a flight is selected. On conflict, merge into the returned current settings (or refetch with keg_settings_read) and retry with the returned current hash.", + Description: "Manage: Call keg_settings_read with minimal=false first, then replace the complete KEG settings with a validated YAML document using its hash as expected_hash. Requires admin access to the KEG itself, plus admin cover when a flight is selected. On conflict, merge into the returned current settings (or refetch with keg_settings_read) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -101,7 +103,7 @@ func createNodeOutputs(results []keg.CreateNodeResult) []createNodeOutput { func registerCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_create", - Description: "Atomically create 1-100 KEG nodes. " + + Description: "Memory: Atomically create 1-100 KEG nodes. " + "Each node is a markdown content document plus an optional YAML metadata document. " + "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. " + @@ -141,8 +143,9 @@ type editInput struct { type editItemInput struct { NodeID string `json:"node_id" jsonschema:"id of the node to update"` Content *string `json:"content,omitempty" jsonschema:"replacement markdown body, replacing the node's content entirely. Must not start with a YAML frontmatter block: metadata goes in the meta field. Omit to leave content unchanged."` + Patch *string `json:"patch,omitempty" jsonschema:"unified diff (the @@ hunks of diff -u) against the node's current content, an alternative to content. Each hunk's context and removed lines must match the current content exactly; header line numbers are a hint. Prefer it for small changes to long notes. The body keeps its line endings; a '\\ No newline at end of file' marker may only follow a hunk that reaches the end."` 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"` + ExpectedContentHash string `json:"expected_content_hash,omitempty" jsonschema:"content_hash from node_read (or from a previous node_edit result); required when content or patch 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, 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."` } @@ -169,10 +172,13 @@ func nodeUpdateOutputs(results []keg.NodeUpdateResult) []nodeUpdateOutput { func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_edit", - Description: "Atomically replace the content and/or metadata of 1-100 nodes. " + - "Supply content, meta, or both for each node; at least one is required. " + - "content is the markdown body and must not begin with a YAML frontmatter block — metadata goes in meta. " + - "Each part has its own precondition token: pass node_read's content_hash as expected_content_hash when supplying content, and its meta_hash as expected_meta_hash when supplying meta. " + + Description: "Memory: Atomically change the content and/or metadata of 1-100 nodes. " + + "For each node supply content or patch, meta, or both; at least one is required. " + + "content is the full replacement markdown body and must not begin with a YAML frontmatter block — metadata goes in meta. " + + "patch is a unified diff against the current body (@@ -a,b +c,d @@ hunks of context ' ', removed '-' and added '+' lines), an alternative to content: prefer it for small changes to a long note, since only the changed lines are sent. " + + "Each hunk's context and removed lines must match the current body exactly and in only one place; the header line numbers are a hint. A patch that does not fit is refused with the lines it expected and nothing is written: re-read the node and rebuild the patch, or send content. " + + "A patch keeps the body's line endings (CRLF stays CRLF), and '\\ No newline at end of file' may only follow a hunk that reaches the end of the body. " + + "Each part has its own precondition token: pass node_read's content_hash as expected_content_hash when supplying content or patch, and its meta_hash as expected_meta_hash when supplying meta. " + "Editing content never invalidates meta_hash and editing meta never invalidates content_hash, so a concurrent edit to the other part does not conflict. " + "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. " + @@ -192,6 +198,9 @@ func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { if item.Content != nil { edit.Content, edit.HasContent = *item.Content, true } + if item.Patch != nil { + edit.ContentPatch, edit.HasContentPatch = *item.Patch, true + } if item.Meta != nil { edit.Meta, edit.HasMeta = *item.Meta, true } @@ -221,8 +230,9 @@ type removeNodeInput struct { func registerRemove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "node_delete", - Description: "Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", + Name: "node_delete", + Description: "Memory: Call node_read first for every node, then atomically remove 1-100 nodes using the hash from node_read (the combined hash, not content_hash or meta_hash) as that node's expected_hash. On conflict, refetch with node_read and retry with the returned current hash. " + + "Where the keg keeps a trash, a removed node goes there with its history and assets and trash_restore brings it back; links to it are left alone until it is purged. Otherwise the node and its snapshots are gone and links to it point at node 0.", InputSchema: boundedMutationInputSchema[removeInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), @@ -260,7 +270,7 @@ type moveInput struct { func registerMove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "node_move", - Description: "Call node_read first, then move (rename) a KEG node to a new ID using the hash from node_read (the combined hash, not content_hash or meta_hash) as expected_hash. On conflict, refetch with node_read and retry with the returned current hash.", + Description: "Memory: Call node_read first, then move (rename) a KEG node to a new ID using the hash from node_read (the combined hash, not content_hash or meta_hash) as expected_hash. The destination must be free: the id of a node in the trash stays reserved until it is restored or purged. On conflict, refetch with node_read and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index e7e91f84..2cc8b3b2 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -37,6 +37,9 @@ var tapMethodToSurfaces = map[string]struct { "ListIndexes": {CLI: "index list", MCP: []string{"index_list"}}, "IndexCat": {CLI: "index read", MCP: []string{"index_read"}}, "Doctor": {CLI: "keg check", MCP: []string{"keg_check"}}, + // Hub web links. `tap url --open` also launches the browser, a CLI-only + // convenience; the link itself is the same on both surfaces. + "WebURL": {CLI: "url", MCP: []string{"url"}}, // Write operations "KegDelete": {CLI: "keg delete", MCP: []string{"keg_delete"}}, @@ -102,10 +105,15 @@ var tapMethodToSurfaces = map[string]struct { // Keg and namespace discovery (hub-side). HubListKegs backs `tap keg list`; // on MCP the same listing is filtered through the session's active flight // cover. - "HubListKegs": {CLI: "keg list", MCP: []string{"keg_list"}}, - "NamespaceList": {CLI: "namespace list", MCP: []string{"namespace_list"}}, - "NamespaceSearch": {CLI: "namespace search", MCP: []string{"namespace_search"}}, - "InvitationList": {CLI: "invitation list", MCP: []string{"invitation_list"}}, + "HubListKegs": {CLI: "keg list", MCP: []string{"keg_list"}}, + "NamespaceList": {CLI: "namespace list", MCP: []string{"namespace_list"}}, + "InvitationList": {CLI: "invitation list", MCP: []string{"invitation_list"}}, + + // The hub's public directory. Searching is reserved for it; the caller's + // own KEGs, flights, and namespaces are listed above. + "ExploreKegs": {CLI: "explore kegs", MCP: []string{"explore_kegs"}}, + "ExploreFlights": {CLI: "explore flights", MCP: []string{"explore_flights"}}, + "ExplorePeople": {CLI: "explore people", MCP: []string{"explore_people"}}, // Agent orientation remains shared. Native plugin installation is an // intentionally CLI-only host operation (see tapMethodsExcluded). @@ -122,6 +130,10 @@ var tapMethodsExcluded = map[string]string{ "NodeSnapshotBatch": "MCP batch backing operation; CLI snapshot create remains a one-node command", "CatViews": "structured accessor behind Cat; MCP node_read uses it to return per-node precondition hashes without re-reading", "NodeHash": "explicit CLI read-before-write helper; MCP reads return the same token in structured content", + "Merge": "MCP node_merge only so far; no tap node merge command yet", + "Replace": "MCP keg_replace only so far; no tap keg replace command yet", + "TrashList": "MCP trash_list only so far; no tap trash command yet", + "TrashRestore": "MCP trash_restore only so far; no tap trash command yet", "SchemaHash": "explicit CLI read-before-write helper; MCP schema_read returns the same token", "KegSettingsHash": "explicit CLI read-before-write helper; MCP keg_settings_read returns the same token", "ConfigEdit": "interactive editor; not exposed via MCP", @@ -163,10 +175,11 @@ var tapMethodsExcluded = map[string]string{ "SetBootstrapFlight": "CLI-only bootstrap step; validates and persists the user-level flight baseline, not an MCP operation", "Use": "writes the project/user keg + flight to config; CLI-only config management by design", "UseStatus": "CLI-only summary of the resolved keg/flight context; config inspection via `tap use`", + "ContextInfo": "CLI-only `tap info` summary of the local hub, keg, and flight defaults; an MCP session learns its identity from session_info and its flight and KEGs from orient", "ActiveFlightName": "internal pure read of the explicit flight or the loaded cascade's selection; backs Orient and MCP session adoption rather than being an operation of its own", "ActiveLaunch": "internal pure read of the `tap launch` harness and model driving the process; reported in orientation rather than being an operation of its own", "OrientationKegsForFlight": "internal authority projection used by MCP providers to compute a revision before rendering once", - "IdentityKegCatalog": "internal identity metadata projection used by MCP providers for graph discovery and ungoverned keg_search", + "IdentityKegCatalog": "internal identity metadata projection used by MCP providers for graph discovery and keg_list", // Dropped from MCP when the surface was unified behind providers: these // operate on machine-local Tapper state or perform tenant administration, // neither of which an agent should reach through either transport. diff --git a/pkg/tapper/auth_whoami.go b/pkg/tapper/auth_whoami.go index 1a312ab6..8b1fe4b1 100644 --- a/pkg/tapper/auth_whoami.go +++ b/pkg/tapper/auth_whoami.go @@ -43,14 +43,19 @@ var ErrTokenRejected = errors.New("hub rejected the token") // fallback namespace so the hub, not the client, decides which namespace a // fresh login lands in. Namespaces lists every namespace the user belongs to // (personal + orgs) and is empty when the hub omits it (older hub). +// +// PersonalNamespace is the namespace of kind user the hub found the account +// owning, read from its database rather than assumed from the username. Nil +// when the hub omits it (older hub) or could not tell; "" when it has none. type WhoAmI struct { - UserID int64 `json:"user_id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` - DefaultNamespace string `json:"default_namespace"` - Namespaces []string `json:"namespaces,omitempty"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + DefaultNamespace string `json:"default_namespace"` + Namespaces []string `json:"namespaces,omitempty"` + PersonalNamespace *string `json:"personal_namespace,omitempty"` } // ValidateToken calls GET {hubURL}/api/v1/whoami with the bearer token and diff --git a/pkg/tapper/auth_whoami_test.go b/pkg/tapper/auth_whoami_test.go index 1791d617..8350f75a 100644 --- a/pkg/tapper/auth_whoami_test.go +++ b/pkg/tapper/auth_whoami_test.go @@ -36,6 +36,27 @@ func TestValidateToken_Success(t *testing.T) { require.Equal(t, int64(42), who.UserID) require.Equal(t, "alice", who.Username) require.Equal(t, "alice@example.com", who.Email) + require.Nil(t, who.PersonalNamespace, "an older hub's silence is not a missing namespace") +} + +// personal_namespace "" (the hub found none) decodes apart from its absence. +func TestValidateToken_PersonalNamespace(t *testing.T) { + t.Parallel() + for _, want := range []string{"alice", ""} { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/whoami", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"user_id": 42, "username": "alice", "personal_namespace": want}) + }) + srv := testapi.NewServer(mux) + t.Cleanup(srv.Close) + + rt, _ := toolkit.NewRuntime() + who, err := tapper.ValidateToken(context.Background(), rt, srv.URL, "thub_abc") + require.NoError(t, err) + require.NotNil(t, who.PersonalNamespace) + require.Equal(t, want, *who.PersonalNamespace) + } } func TestValidateToken_Unauthorized(t *testing.T) { diff --git a/pkg/tapper/compact_orientation_test.go b/pkg/tapper/compact_orientation_test.go index f26f9385..a1540664 100644 --- a/pkg/tapper/compact_orientation_test.go +++ b/pkg/tapper/compact_orientation_test.go @@ -32,7 +32,11 @@ func TestCompactOrientationProgressiveMetadata(t *testing.T) { noflight, err := BuildOrientationPayload(nil, "", "", kegs, nil, &OrientationAuthority{FullAccess: true, AvailableFlights: []string{root.Name}, Children: children}) require.NoError(t, err) require.NotContains(t, noflight, "@n/") - require.Contains(t, noflight, "flight_search") + require.Contains(t, noflight, "flight_list", "the no-flight payload points at the complete private list") + require.Contains(t, noflight, "explore_flights", "and at the public directory") + for _, c := range ToolCategories { + require.Contains(t, noflight, string(c.Category)+": "+c.Intent, "orientation explains every tool category") + } // Representative legacy payload sections: rules, full catalog, and all canonical guides. var before strings.Builder before.WriteString(OrientationOperatingRules()) diff --git a/pkg/tapper/flight.go b/pkg/tapper/flight.go index 8698c3ba..a37c61c2 100644 --- a/pkg/tapper/flight.go +++ b/pkg/tapper/flight.go @@ -26,6 +26,9 @@ const ( type FlightRole string const ( + // FlightRoleNone blocks a KEG: listed with it, the KEG is out of the + // flight's cover however its links reach it, and grants nothing. + FlightRoleNone FlightRole = "none" FlightRoleViewer FlightRole = "viewer" FlightRoleEditor FlightRole = "editor" FlightRoleAdmin FlightRole = "admin" @@ -64,6 +67,8 @@ func (r FlightRole) AtLeast(want FlightRole) bool { func normalizeFlightRole(role FlightRole) FlightRole { switch FlightRole(strings.TrimSpace(string(role))) { + case FlightRoleNone: + return FlightRoleNone case FlightRoleAdmin: return FlightRoleAdmin case FlightRoleEditor: @@ -595,7 +600,7 @@ func validateFlightManifest(m *FlightManifest, namespace string) error { return fmt.Errorf("invalid flight cover depth %d: must be 1–8", cover.Depth) } switch normalizeFlightRole(cover.Role) { - case FlightRoleViewer, FlightRoleEditor, FlightRoleAdmin: + case FlightRoleNone, FlightRoleViewer, FlightRoleEditor, FlightRoleAdmin: default: return fmt.Errorf("invalid flight cover role %q", cover.Role) } @@ -700,15 +705,20 @@ func (f *Flight) RoleFor(alias, namespace, kegName string) (FlightRole, bool) { kegName = strings.TrimSpace(kegName) alias = strings.TrimSpace(alias) for _, c := range cover { + match := false if c.Namespace == "" { - if (alias != "" && c.Keg == alias) || (kegName != "" && c.Keg == kegName) { - return normalizeFlightRole(c.Role), true - } + match = (alias != "" && c.Keg == alias) || (kegName != "" && c.Keg == kegName) + } else { + match = c.Namespace == namespace && c.Keg == kegName + } + if !match { continue } - if c.Namespace == namespace && c.Keg == kegName { - return normalizeFlightRole(c.Role), true + // A blocked KEG is not covered, whatever else names it. + if role := normalizeFlightRole(c.Role); role != FlightRoleNone { + return role, true } + return "", false } return "", false } @@ -727,6 +737,8 @@ func ParseFlightCoverSpecs(specs []string) ([]FlightCover, error) { role := FlightRoleViewer if hasRole { switch FlightRole(strings.TrimSpace(roleRaw)) { + case FlightRoleNone: + role = FlightRoleNone case FlightRoleViewer: role = FlightRoleViewer case FlightRoleEditor: diff --git a/pkg/tapper/flight_test.go b/pkg/tapper/flight_test.go index 4ee6c3af..3a1ef64a 100644 --- a/pkg/tapper/flight_test.go +++ b/pkg/tapper/flight_test.go @@ -453,3 +453,18 @@ func TestFlattenFlightGraph_ToleratesCycles(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"@team/+y", "@team/+z"}, ringGraph.AvailableRefs()) } + +// A cover entry with role none blocks its KEG: specs accept it, and it +// covers nothing, even when another entry names the same KEG. +func TestFlightCoverNoneBlocks(t *testing.T) { + cover, err := tapper.ParseFlightCoverSpecs([]string{"@team/notes=viewer", "@team/secret=none"}) + require.NoError(t, err) + require.Equal(t, tapper.FlightRoleNone, cover[1].Role) + flight := &tapper.Flight{Namespace: "team", FlightManifest: tapper.FlightManifest{Cover: cover}} + _, ok := flight.RoleFor("", "team", "secret") + require.False(t, ok, "a blocked KEG is not covered") + role, ok := flight.RoleFor("", "team", "notes") + require.True(t, ok) + require.Equal(t, tapper.FlightRoleViewer, role) + require.False(t, tapper.FlightRoleNone.AtLeast(tapper.FlightRoleViewer)) +} diff --git a/pkg/tapper/hub_agents_test.go b/pkg/tapper/hub_agents_test.go index 9b4da4c4..7b36b564 100644 --- a/pkg/tapper/hub_agents_test.go +++ b/pkg/tapper/hub_agents_test.go @@ -31,11 +31,12 @@ func TestHubAgents_ClientPaths(t *testing.T) { _ = json.NewEncoder(w).Encode(agent) case "DELETE /api/v1/@acme/agents/writer": w.WriteHeader(http.StatusNoContent) - case "POST /api/v1/namespaces/search": - var body map[string]string + case "POST /api/v1/explore": + var body tapper.ExploreRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Equal(t, "ac", body["query"]) - _ = json.NewEncoder(w).Encode(tapper.NamespaceSearchResult{Namespaces: []tapper.NamespaceMatch{{Name: "acme", Kind: "org"}}}) + require.Equal(t, tapper.ExplorePeople, body.Kind) + require.Equal(t, "ac", body.Query) + _ = json.NewEncoder(w).Encode(tapper.ExploreResult{Kind: tapper.ExplorePeople, Results: []tapper.ExploreRow{{Ref: "@acme", Name: "Acme"}}}) default: http.NotFound(w, r) } @@ -58,13 +59,13 @@ func TestHubAgents_ClientPaths(t *testing.T) { require.Equal(t, "Scribe", wrote.Title) require.NoError(t, tapper.DeleteHubAgent(ctx, srv.URL, "tok", "acme", "writer")) - found, err := tapper.SearchHubNamespaces(ctx, srv.URL, "tok", "ac") + found, err := tapper.ExploreHub(ctx, srv.URL, "tok", tapper.ExplorePeople, "ac") require.NoError(t, err) - require.Equal(t, "acme", found.Namespaces[0].Name) + require.Equal(t, "@acme", found.Results[0].Ref) require.Equal(t, []string{ "GET /api/v1/agents", "GET /api/v1/@acme/agents", "POST /api/v1/@acme/agents", - "PUT /api/v1/@acme/agents/writer", "DELETE /api/v1/@acme/agents/writer", "POST /api/v1/namespaces/search", + "PUT /api/v1/@acme/agents/writer", "DELETE /api/v1/@acme/agents/writer", "POST /api/v1/explore", }, seen) // An older hub sends no effective tools; the stored names are the list. diff --git a/pkg/tapper/hub_namespaces.go b/pkg/tapper/hub_namespaces.go index 1ad698f5..426dbd29 100644 --- a/pkg/tapper/hub_namespaces.go +++ b/pkg/tapper/hub_namespaces.go @@ -90,15 +90,3 @@ func ListNamespaces(ctx context.Context, hubURL, token string) ([]HubNamespace, func CreateNamespace(ctx context.Context, hubURL, token, name string) (*HubNamespace, error) { return nil, fmt.Errorf("hub namespace creation is disabled for remote clients; use the hub UI: %w", keg.ErrNotSupported) } - -// SearchHubNamespaces searches the hub's visible namespaces by name. -func SearchHubNamespaces(ctx context.Context, hubURL, token, query string) (NamespaceSearchResult, error) { - var out NamespaceSearchResult - if err := doHubJSON(ctx, http.MethodPost, hubURL, token, "/api/v1/namespaces/search", map[string]string{"query": query}, &out); err != nil { - return NamespaceSearchResult{}, err - } - if out.Namespaces == nil { - out.Namespaces = []NamespaceMatch{} - } - return out, nil -} diff --git a/pkg/tapper/invocation_telemetry_test.go b/pkg/tapper/invocation_telemetry_test.go index cd1ff638..f08bdf86 100644 --- a/pkg/tapper/invocation_telemetry_test.go +++ b/pkg/tapper/invocation_telemetry_test.go @@ -19,7 +19,7 @@ type telemetryRoundTripFunc func(*http.Request) (*http.Response, error) func (f telemetryRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Path == "/api/version" { - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-09-28"]}`))}, nil + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"server_version":"test","api_versions":["2026-10-12"]}`))}, nil } return f(req) diff --git a/pkg/tapper/launch_split_test.go b/pkg/tapper/launch_split_test.go index 06cac1b0..f616b44a 100644 --- a/pkg/tapper/launch_split_test.go +++ b/pkg/tapper/launch_split_test.go @@ -190,7 +190,7 @@ func TestLaunchForwarder_NoRelayMCP(t *testing.T) { func TestResolveLaunch_ClaudeAgentExtras(t *testing.T) { t.Parallel() hub := fakeAgentHub(t, "hub-token", twoModels, map[string]string{ - "/api/v1/tools": `{"tools":["keg_search","node_read"],"always_available":["orient"]}`, + "/api/v1/tools": `{"tools":["explore_kegs","node_read"],"always_available":["orient"]}`, "/api/v1/relay/tools": `{"data":[{"name":"mcp__me__everything__echo"}]}`, "/api/v1/@me/agents/lead": `{"ref":"@me/lead","namespace":"me","name":"lead","instructions":"Lead.","model":"@me/llama3", "tools":["relay:tools"],"runtime_tools":[],"relay_tools":true,"subagents":["@me/reader","@me/open","@me/gone"]}`, @@ -224,7 +224,7 @@ func TestResolveLaunch_ClaudeAgentExtras(t *testing.T) { require.Equal(t, "Open", open.Description) require.Empty(t, open.Model, "a model the launch cannot reach is left to inherit") require.Equal(t, []string{"Read", "Grep", "Glob", - "mcp__plugin_tapper_tapper__keg_search", "mcp__plugin_tapper_tapper__node_read", "mcp__plugin_tapper_tapper__orient"}, open.Tools, + "mcp__plugin_tapper_tapper__explore_kegs", "mcp__plugin_tapper_tapper__node_read", "mcp__plugin_tapper_tapper__orient"}, open.Tools, "every Hub tool, and no relayed tool without relay_tools") got, err = newLaunchTap(t, hub.URL, "").ResolveLaunch(LaunchOptions{Harness: "claude", Agent: "@me/lead", Subagents: "none", SubagentBuiltins: []string{}}) diff --git a/pkg/tapper/node_ref_resolve.go b/pkg/tapper/node_ref_resolve.go index 87c801a3..d0b943da 100644 --- a/pkg/tapper/node_ref_resolve.go +++ b/pkg/tapper/node_ref_resolve.go @@ -9,7 +9,7 @@ import ( ) // RefContext carries the "current keg" so a node reference can resolve a -// keg-local alias against that keg's Links table and imply the hub for a +// keg-local alias against that keg's aliases and imply the hub for a // qualified reference from the current keg's hub. type RefContext struct { CurrentKeg keg.Keg @@ -19,7 +19,7 @@ type RefContext struct { // the numeric node id within that keg: // // - RefLocal: the current keg, with the bare node id. -// - RefAlias: the alias is resolved against the current keg's Links table +// - RefAlias: the alias is resolved against the current keg's aliases // first (so authored links travel with the keg), then the // Tapper configuration. // - RefQualified: a (hub, namespace, keg) reference whose hub is implied from @@ -40,7 +40,7 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex if err != nil { return nil, keg.NodeId{}, err } - namespace, name, err := keg.ResolveRelationshipAlias(settings.Links, ref.Alias) + namespace, name, err := keg.ResolveSettingsAlias(settings.Aliases, ref.Alias) if err != nil { return nil, keg.NodeId{}, err } @@ -94,7 +94,7 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex // // - "" / "-" operate on currentKeg (unchanged behavior). // - "keg:/" redirect to the keg the alias names (the -// current keg's Links table first, then tap-config kegs). +// current keg's aliases first, then tap-config kegs). // - "keg:@//" redirect to the fully qualified keg; the hub // is implied from currentKeg's hub. // diff --git a/pkg/tapper/tap.go b/pkg/tapper/tap.go index 483eb797..78924a8e 100644 --- a/pkg/tapper/tap.go +++ b/pkg/tapper/tap.go @@ -46,6 +46,11 @@ type Tap struct { // endpoint injects its in-process TAP service here. Operations Operations + // WebBaseURL is the public web origin of the Hub serving this Tap. A Hub + // that injects KegResolver sets it so WebURL links point at the Hub + // itself; the CLI leaves it empty and links to the configured hub. + WebBaseURL string + // Version is the version of the binary running this Tap, already // normalized by NewTap. `tap integrate` stamps it into every plugin // manifest it extracts, so an installed plugin always reports the tap diff --git a/pkg/tapper/tap_auth.go b/pkg/tapper/tap_auth.go index 92ef4790..3e330e62 100644 --- a/pkg/tapper/tap_auth.go +++ b/pkg/tapper/tap_auth.go @@ -112,6 +112,11 @@ type AuthStatusHub struct { // Empty when the hub omits it or validation didn't run. DisplayName string + // PersonalNamespace is the personal namespace the hub reported for the + // account. Nil when the hub didn't say (older hub, --offline, failed + // probe); a pointer to "" when the hub found none. + PersonalNamespace *string + // Valid is true when the live whoami probe confirmed the token. False // when the token was rejected, the hub was unreachable, or --offline // skipped the check. @@ -181,6 +186,11 @@ type AuthStatusResult struct { // Empty when the hub omits it or validation didn't run. DisplayName string + // PersonalNamespace is the personal namespace the hub reported for the + // account. Nil when the hub didn't say (older hub, --offline, failed + // probe); a pointer to "" when the hub found none. + PersonalNamespace *string + // Valid is true when the live whoami probe confirmed the token. False // when the token was rejected, the hub was unreachable, or --offline // skipped the check. @@ -297,21 +307,22 @@ func (t *Tap) AuthStatus(ctx context.Context, opts AuthStatusOptions) (*AuthStat func authStatusResultFromHub(status AuthStatusHub) *AuthStatusResult { return &AuthStatusResult{ - Present: status.Present, - HubURL: status.HubURL, - TokenPrefix: status.TokenPrefix, - TokenType: status.TokenType, - Account: status.Account, - DisplayName: status.DisplayName, - Valid: status.Valid, - ValidationError: status.ValidationError, - Scope: status.Scope, - ExpiresAt: status.ExpiresAt, - ExpiryStatus: status.ExpiryStatus, - LoginMethod: status.LoginMethod, - Renewable: status.Renewable, - Hubs: []AuthStatusHub{status}, - Formatted: status.Formatted, + Present: status.Present, + HubURL: status.HubURL, + TokenPrefix: status.TokenPrefix, + TokenType: status.TokenType, + Account: status.Account, + DisplayName: status.DisplayName, + PersonalNamespace: status.PersonalNamespace, + Valid: status.Valid, + ValidationError: status.ValidationError, + Scope: status.Scope, + ExpiresAt: status.ExpiresAt, + ExpiryStatus: status.ExpiryStatus, + LoginMethod: status.LoginMethod, + Renewable: status.Renewable, + Hubs: []AuthStatusHub{status}, + Formatted: status.Formatted, } } @@ -384,6 +395,7 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, var ( account string display string + personal *string valid bool validErr string statusLine string @@ -405,6 +417,7 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, valid = true account = who.Username display = who.DisplayName + personal = who.PersonalNamespace ident := account if display != "" { ident = fmt.Sprintf("%s (%s)", account, display) @@ -451,6 +464,16 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, var b strings.Builder fmt.Fprintf(&b, "%s\n", host) fmt.Fprintf(&b, "%s\n", statusLine) + // The personal namespace is the hub's answer, not the username assumed: + // relayed MCP tools are granted from it, so a missing one is worth seeing. + // An older hub doesn't report it, and then there is no line. + if personal != nil { + if *personal == "" { + b.WriteString(" - Namespace: none (the hub has no personal namespace for this account)\n") + } else { + fmt.Fprintf(&b, " - Namespace: @%s\n", *personal) + } + } fmt.Fprintf(&b, "%s\n", methodLine) // The token prefix is only useful for a pasted API token: the hub lists // token[:12] in the account UI, so showing the same prefix lets the user @@ -476,20 +499,21 @@ func (t *Tap) authStatusForHub(ctx context.Context, store *AuthStore, storePath, } return AuthStatusHub{ - Present: true, - HubURL: hub, - TokenPrefix: tokenPrefix, - TokenType: entry.TokenType, - Account: account, - DisplayName: display, - Valid: valid, - ValidationError: validErr, - Scope: entry.Scope, - ExpiresAt: entry.ExpiresAt, - ExpiryStatus: expiryStatus, - LoginMethod: loginMethod, - Renewable: renewable, - Formatted: b.String(), + Present: true, + HubURL: hub, + TokenPrefix: tokenPrefix, + TokenType: entry.TokenType, + Account: account, + DisplayName: display, + PersonalNamespace: personal, + Valid: valid, + ValidationError: validErr, + Scope: entry.Scope, + ExpiresAt: entry.ExpiresAt, + ExpiryStatus: expiryStatus, + LoginMethod: loginMethod, + Renewable: renewable, + Formatted: b.String(), }, nil } diff --git a/pkg/tapper/tap_auth_test.go b/pkg/tapper/tap_auth_test.go index 28637636..3be819e4 100644 --- a/pkg/tapper/tap_auth_test.go +++ b/pkg/tapper/tap_auth_test.go @@ -223,6 +223,36 @@ func TestTap_AuthStatus(t *testing.T) { require.NotContains(t, res.Formatted, tok) }) + t.Run("personal namespace is shown as the hub reports it", func(t *testing.T) { + t.Parallel() + named, none := "alice", "" + for _, tc := range []struct { + name string + personal *string + want string + }{ + {"set", &named, " ✓ Logged in as alice\n - Namespace: @alice\n"}, + {"none", &none, " ✓ Logged in as alice\n - Namespace: none (the hub has no personal namespace for this account)\n"}, + {"older hub", nil, " ✓ Logged in as alice\n - Method:"}, + } { + t.Run(tc.name, func(t *testing.T) { + sb := NewSandbox(t) + tap := newTestTap(t, sb) + seedStore(t, sb, tap, map[string]tapper.AuthEntry{ + "https://hub.example.com": {AccessToken: "thub_personalns0000", TokenType: "Bearer"}, + }) + tap.AuthValidateFn = func(context.Context, *toolkit.Runtime, string, string) (*tapper.WhoAmI, error) { + return &tapper.WhoAmI{Username: "alice", PersonalNamespace: tc.personal}, nil + } + + res, err := tap.AuthStatus(sb.Context(), tapper.AuthStatusOptions{}) + require.NoError(t, err) + require.Contains(t, res.Formatted, tc.want) + require.Equal(t, tc.personal, res.PersonalNamespace) + }) + } + }) + t.Run("valid token without display name omits parens", func(t *testing.T) { t.Parallel() sb := NewSandbox(t) diff --git a/pkg/tapper/tap_batch.go b/pkg/tapper/tap_batch.go index 58fe1fdd..b99002e3 100644 --- a/pkg/tapper/tap_batch.go +++ b/pkg/tapper/tap_batch.go @@ -41,8 +41,12 @@ type BatchEditItem struct { Schema string Content string HasContent bool - Meta string - HasMeta bool + // ContentPatch is a unified diff against the node's current content, + // an alternative to Content. + ContentPatch string + HasContentPatch bool + Meta string + HasMeta bool // ExpectedHash is the combined content+metadata token (node_read's // hash). It is used only when neither part token is set; the CLI and // other single-token callers rely on it. @@ -66,41 +70,56 @@ func (t *Tap) EditBatch(ctx context.Context, opts BatchEditOptions) ([]keg.NodeU ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) updates := make([]keg.NodeUpdateOptions, len(opts.Edits)) for i, item := range opts.Edits { - id, err := keg.ParseNode(item.NodeID) + update, err := batchEditUpdate(ctx, i, item) if err != nil { - return nil, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) - } - if !item.HasContent && !item.HasMeta { - return nil, fmt.Errorf("edit %d node %q: content or meta is required: %w", i, item.NodeID, keg.ErrInvalid) - } - if err := requireEditPartHashes(i, item); err != nil { return nil, err } - update := keg.NodeUpdateOptions{ - ID: *id, - Schema: item.Schema, - ExpectedHash: item.ExpectedHash, - ExpectedContentHash: item.ExpectedContentHash, - ExpectedMetaHash: item.ExpectedMetaHash, - } - if item.HasContent { - if err := keg.RejectFrontmatter([]byte(item.Content)); err != nil { - return nil, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) - } - update.Content, update.HasContent = []byte(item.Content), true - } - if item.HasMeta { - meta, err := keg.ParseMeta(ctx, []byte(item.Meta)) - if err != nil { - return nil, fmt.Errorf("edit %d node %q metadata: %w", i, item.NodeID, err) - } - update.Meta, update.HasMeta = []byte(meta.ToYAML()), true - } updates[i] = update } return k.UpdateNodes(ctx, updates) } +// batchEditUpdate checks one edit item and turns it into the keg update. +func batchEditUpdate(ctx context.Context, i int, item BatchEditItem) (keg.NodeUpdateOptions, error) { + id, err := keg.ParseNode(item.NodeID) + if err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) + } + if !item.HasContent && !item.HasContentPatch && !item.HasMeta { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: content, patch, or meta is required: %w", i, item.NodeID, keg.ErrInvalid) + } + if item.HasContent && item.HasContentPatch { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: content and patch are alternatives; send one: %w", i, item.NodeID, keg.ErrInvalid) + } + if err := requireEditPartHashes(i, item); err != nil { + return keg.NodeUpdateOptions{}, err + } + update := keg.NodeUpdateOptions{ + ID: *id, + Schema: item.Schema, + ExpectedHash: item.ExpectedHash, + ExpectedContentHash: item.ExpectedContentHash, + ExpectedMetaHash: item.ExpectedMetaHash, + } + if item.HasContent { + if err := keg.RejectFrontmatter([]byte(item.Content)); err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q: %w", i, item.NodeID, err) + } + update.Content, update.HasContent = []byte(item.Content), true + } + if item.HasContentPatch { + update.ContentPatch, update.HasContentPatch = item.ContentPatch, true + } + if item.HasMeta { + meta, err := keg.ParseMeta(ctx, []byte(item.Meta)) + if err != nil { + return keg.NodeUpdateOptions{}, fmt.Errorf("edit %d node %q metadata: %w", i, item.NodeID, err) + } + update.Meta, update.HasMeta = []byte(meta.ToYAML()), true + } + return update, nil +} + type BatchSnapshotItem struct { NodeID string Message string @@ -135,6 +154,9 @@ func requireEditPartHashes(i int, item BatchEditItem) error { if item.ExpectedHash != "" && item.ExpectedContentHash == "" && item.ExpectedMetaHash == "" { return nil } + if item.HasContentPatch && item.ExpectedContentHash == "" { + return fmt.Errorf("edit %d node %q: patch requires expected_content_hash (the content_hash from node_read): %w", i, item.NodeID, keg.ErrPreconditionRequired) + } if item.HasContent && item.ExpectedContentHash == "" { return fmt.Errorf("edit %d node %q: content requires expected_content_hash (the content_hash from node_read): %w", i, item.NodeID, keg.ErrPreconditionRequired) } @@ -143,3 +165,33 @@ func requireEditPartHashes(i int, item BatchEditItem) error { } return nil } + +type MergeOptions struct { + KegTargetOptions + SourceID string + ExpectedSourceHash string + // Target is the surviving node's rewrite, as for one EditBatch item. + Target BatchEditItem +} + +// Merge folds a source node into a target: the target takes the merged +// body, links to the source follow it, and the source goes to the trash. +func (t *Tap) Merge(ctx context.Context, opts MergeOptions) (keg.MergeNodesResult, error) { + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + if err != nil { + return keg.MergeNodesResult{}, err + } + ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) + source, err := keg.ParseNode(opts.SourceID) + if err != nil { + return keg.MergeNodesResult{}, fmt.Errorf("merge source %q: %w", opts.SourceID, err) + } + if !opts.Target.HasContent && !opts.Target.HasContentPatch { + return keg.MergeNodesResult{}, fmt.Errorf("merge into %q: content or patch is required, since a merge rewrites the target: %w", opts.Target.NodeID, keg.ErrInvalid) + } + update, err := batchEditUpdate(ctx, 0, opts.Target) + if err != nil { + return keg.MergeNodesResult{}, err + } + return k.MergeNodes(ctx, keg.MergeNodesOptions{Source: *source, ExpectedSourceHash: opts.ExpectedSourceHash, Target: update}) +} diff --git a/pkg/tapper/tap_explore.go b/pkg/tapper/tap_explore.go new file mode 100644 index 00000000..f9db0c82 --- /dev/null +++ b/pkg/tapper/tap_explore.go @@ -0,0 +1,104 @@ +package tapper + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// ExploreKind names one section of a hub's public directory. +type ExploreKind string + +const ( + // ExploreKegs lists public KEGs. + ExploreKegs ExploreKind = "kegs" + // ExploreFlights lists public flights. + ExploreFlights ExploreKind = "flights" + // ExplorePeople lists the people and org namespaces on the hub. + ExplorePeople ExploreKind = "people" +) + +// ExploreOptions searches one section of the public directory. Query must +// not be empty: the directory is hub-wide, and the caller's own resources +// come from the list commands instead. +type ExploreOptions struct { + Query string + Hub string +} + +// ExploreRow is one public directory entry. Role is the caller's role when +// they already belong to it, empty otherwise. A row grants nothing. +type ExploreRow struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Role string `json:"role,omitempty"` +} + +// ExploreResult is a bounded page of public directory matches. +type ExploreResult struct { + Kind ExploreKind `json:"kind"` + Results []ExploreRow `json:"results"` + Truncated bool `json:"truncated"` +} + +// ExploreRequest is the body of POST /api/v1/explore. +type ExploreRequest struct { + Kind ExploreKind `json:"kind"` + Query string `json:"query"` +} + +// ValidExploreKind reports whether kind names a directory section. +func ValidExploreKind(kind ExploreKind) bool { + switch kind { + case ExploreKegs, ExploreFlights, ExplorePeople: + return true + } + return false +} + +// ExploreKegs searches the hub's public KEGs. +func (t *Tap) ExploreKegs(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExploreKegs, opts) +} + +// ExploreFlights searches the hub's public flights. +func (t *Tap) ExploreFlights(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExploreFlights, opts) +} + +// ExplorePeople searches the people and org namespaces on the hub. +func (t *Tap) ExplorePeople(ctx context.Context, opts ExploreOptions) (ExploreResult, error) { + return t.explore(ctx, ExplorePeople, opts) +} + +func (t *Tap) explore(ctx context.Context, kind ExploreKind, opts ExploreOptions) (ExploreResult, error) { + query := strings.TrimSpace(opts.Query) + if query == "" { + return ExploreResult{}, fmt.Errorf("%w: query must not be empty", keg.ErrInvalid) + } + hubURL, token, err := t.resolveHubEndpoint(opts.Hub) + if err != nil { + return ExploreResult{}, err + } + return ExploreHub(ctx, hubURL, token, kind, query) +} + +// ExploreHub searches one section of a hub's public directory via +// POST /api/v1/explore. +func ExploreHub(ctx context.Context, hubURL, token string, kind ExploreKind, query string) (ExploreResult, error) { + var out ExploreResult + if err := doHubJSON(ctx, http.MethodPost, hubURL, token, "/api/v1/explore", ExploreRequest{Kind: kind, Query: query}, &out); err != nil { + return ExploreResult{}, err + } + if out.Kind == "" { + out.Kind = kind + } + if out.Results == nil { + out.Results = []ExploreRow{} + } + return out, nil +} diff --git a/pkg/tapper/tap_info.go b/pkg/tapper/tap_info.go index 95eb1e78..f7c57c2d 100644 --- a/pkg/tapper/tap_info.go +++ b/pkg/tapper/tap_info.go @@ -507,3 +507,92 @@ func (t *Tap) KegSettingsEdit(ctx context.Context, opts KegSettingsEditOptions) } return nil } + +// ContextInfoHub is the hub the CLI talks to and who it is signed in as. +type ContextInfoHub struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` + User string `yaml:"user,omitempty" json:"user,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoKeg is the CLI's default KEG. +type ContextInfoKeg struct { + Ref string `yaml:"ref,omitempty" json:"ref,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoFlight is the active flight. +type ContextInfoFlight struct { + Ref string `yaml:"ref,omitempty" json:"ref,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} + +// ContextInfoFlightKeg is one KEG the active flight covers, at the role the +// caller holds through it. +type ContextInfoFlightKeg struct { + Ref string `yaml:"ref" json:"ref"` + Role string `yaml:"role,omitempty" json:"role,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` +} + +// ContextInfoResult summarizes the CLI's context for `tap info`. +type ContextInfoResult struct { + Hub ContextInfoHub `yaml:"hub" json:"hub"` + Keg ContextInfoKeg `yaml:"keg" json:"keg"` + Flight ContextInfoFlight `yaml:"flight" json:"flight"` + Kegs []ContextInfoFlightKeg `yaml:"kegs" json:"kegs"` + Warnings []string `yaml:"warnings,omitempty" json:"warnings,omitempty"` +} + +// ContextInfo reports the hub, the default KEG, the active flight, and the +// KEGs that flight covers. It is best-effort: a hub that cannot be reached or +// a flight that cannot be loaded becomes a warning, and the locally +// configured defaults are still reported. +func (t *Tap) ContextInfo(ctx context.Context, opts KegTargetOptions) (ContextInfoResult, error) { + out := ContextInfoResult{Kegs: []ContextInfoFlightKeg{}} + slots := t.configSlots(opts) + + out.Hub = ContextInfoHub{Name: slots.Hub.Value, Scope: slots.Hub.Scope} + if name, entry, err := t.ConfigService.SelectedHub(opts.Hub); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("hub: %v", err)) + } else { + out.Hub.Name, out.Hub.URL = name, entry.URL + if who, err := t.SelectedHubIdentity(ctx); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("hub identity: %v", err)) + } else if who != nil { + out.Hub.User = who.Username + } + } + + out.Keg = ContextInfoKeg{Ref: slots.Keg.Value, Scope: slots.Keg.Scope} + if ref := t.resolveIdentity(opts).Ref; ref != "" { + out.Keg.Ref = ref + } + + out.Flight = ContextInfoFlight{Ref: t.ActiveFlightName(opts.Flight), Scope: slots.Flight.Scope} + if out.Flight.Ref == "" { + return out, nil + } + flight, note, err := t.resolveOrientFlight(ctx, out.Flight.Ref) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("flight: %v", err)) + return out, nil + } + if note != "" { + out.Warnings = append(out.Warnings, note) + return out, nil + } + out.Flight.Ref, out.Flight.Title = flight.Name, flight.Title + rows, warnings, err := t.OrientationKegsForFlight(ctx, flight) + out.Warnings = append(out.Warnings, warnings...) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("flight kegs: %v", err)) + return out, nil + } + for _, row := range rows { + out.Kegs = append(out.Kegs, ContextInfoFlightKeg{Ref: row.Ref, Role: EffectiveOrientationRole(row), Title: row.Title}) + } + return out, nil +} diff --git a/pkg/tapper/tap_namespace.go b/pkg/tapper/tap_namespace.go index 9e5b4633..3d9f38aa 100644 --- a/pkg/tapper/tap_namespace.go +++ b/pkg/tapper/tap_namespace.go @@ -299,35 +299,3 @@ func notLoggedInError(hubName, hubURL string) error { 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 { - Query string - Hub string -} - -// NamespaceMatch is one namespace a search found. Role is the caller's role -// when they belong to it, empty otherwise. -type NamespaceMatch struct { - Name string `json:"name"` - Kind string `json:"kind"` - DisplayName string `json:"display_name,omitempty"` - Role string `json:"role,omitempty"` -} - -// NamespaceSearchResult is a bounded page of matches. -type NamespaceSearchResult struct { - Namespaces []NamespaceMatch `json:"namespaces"` - Truncated bool `json:"truncated"` -} - -// NamespaceSearch finds namespaces on the selected hub by name or display -// name. It is discovery only: a match grants nothing. -func (t *Tap) NamespaceSearch(ctx context.Context, opts NamespaceSearchOptions) (NamespaceSearchResult, error) { - hubURL, token, err := t.resolveHubEndpoint(opts.Hub) - if err != nil { - return NamespaceSearchResult{}, err - } - return SearchHubNamespaces(ctx, hubURL, token, strings.TrimSpace(opts.Query)) -} diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index ec4275f7..d9386f4d 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -402,6 +402,7 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O var b strings.Builder b.WriteString("# KEG System\n\n") b.WriteString("Call `orient` at session start and after every context reset. Authority is bounded by identity permissions and the selected flight; child authority and instructions are not inherited. Use only `mcp__tapper__*` tools for KEG operations. Call `keg_settings_read` before operating in a KEG. Snapshot before meaningful edits; snapshots do not protect deletion. Re-read for a fresh hash before each guarded write; `node_edit` takes per-part hashes (`content_hash`, `meta_hash`), `node_delete` and `node_move` the combined `hash`. Call `guide` for detailed operating, authoring/linking, snapshots, tools, or troubleshooting guidance.\n\n") + writeToolCategories(&b) for _, warning := range warnings { fmt.Fprintf(&b, "Warning: %s\n\n", orientationTableCell(warning)) } @@ -415,9 +416,9 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O } if flight == nil { if authority != nil && authority.FullAccess { - b.WriteString("No flight is active. This session runs under your full identity authority; nothing here is scoped. Use `keg_search` and `flight_search` to discover readable resources; results confer no access. An explicit flight selects only that flight for one call. Pin a least-privilege flight outside MCP and start a new connection to narrow the session.\n") + b.WriteString("No flight is active. This session runs under your full identity authority; nothing here is scoped. `keg_list` and `flight_list` list every readable resource; `explore_kegs` and `explore_flights` search the public directory and confer no access. An explicit flight selects only that flight for one call. Pin a least-privilege flight outside MCP and start a new connection to narrow the session.\n") } else { - b.WriteString("The configured flight is unavailable: fail-closed recovery; KEG tools are locked. Repair the selection, then call `session_refresh` and `orient`. Use `flight_search`, `flight_list`, `flight_read`, or `session_info` for recovery.\n") + b.WriteString("The configured flight is unavailable: fail-closed recovery; KEG tools are locked. Repair the selection, then call `session_refresh` and `orient`. Use `flight_list`, `flight_read`, or `session_info` for recovery.\n") } return b.String(), nil } @@ -450,7 +451,7 @@ func BuildOrientationPayload(flight *Flight, flightNote, launch string, kegs []O } else { writeOrientationKegTable(&b, usable, "") } - b.WriteString("\nInspect a flight explicitly to see its children. Use `keg_search` and `flight_search` for metadata discovery. Select a reachable descendant explicitly with the `flight` argument; its instructions apply only to that call.\n") + b.WriteString("\nInspect a flight explicitly to see its children. Use `keg_list` and `flight_list` for your own resources and `explore_kegs` and `explore_flights` for the public directory. Select a reachable descendant explicitly with the `flight` argument; its instructions apply only to that call.\n") return b.String(), nil } @@ -629,3 +630,13 @@ func appendCanonical(b *strings.Builder, name string) error { } return nil } + +// writeToolCategories tells the agent what each tool category is for. Every +// tool description opens with its category label, so this is the key. +func writeToolCategories(b *strings.Builder) { + b.WriteString("Tool categories:\n") + for _, c := range ToolCategories { + fmt.Fprintf(b, "- %s: %s\n", c.Category, c.Intent) + } + b.WriteString("\n") +} diff --git a/pkg/tapper/tap_replace.go b/pkg/tapper/tap_replace.go new file mode 100644 index 00000000..0fc510ed --- /dev/null +++ b/pkg/tapper/tap_replace.go @@ -0,0 +1,27 @@ +package tapper + +import ( + "context" + + "github.com/jlrickert/tapper/pkg/keg" +) + +type ReplaceOptions struct { + KegTargetOptions + keg.ReplaceTextOptions +} + +// Replace finds and replaces text across a keg. A dry run only reads, so it +// needs a viewer; a real one needs an editor. +func (t *Tap) Replace(ctx context.Context, opts ReplaceOptions) (keg.ReplaceTextResult, error) { + role := FlightRoleEditor + if opts.DryRun { + role = FlightRoleViewer + } + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, role) + if err != nil { + return keg.ReplaceTextResult{}, err + } + ctx = keg.WithDefaultValidationActor(ctx, keg.ValidationActorHuman) + return k.ReplaceText(ctx, opts.ReplaceTextOptions) +} diff --git a/pkg/tapper/tap_trash.go b/pkg/tapper/tap_trash.go new file mode 100644 index 00000000..94e0751c --- /dev/null +++ b/pkg/tapper/tap_trash.go @@ -0,0 +1,39 @@ +package tapper + +import ( + "context" + "fmt" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// TrashList returns the keg's removed nodes, newest first. +func (t *Tap) TrashList(ctx context.Context, opts KegTargetOptions) ([]keg.TrashEntry, error) { + k, err := t.resolveKegForRole(ctx, opts, FlightRoleViewer) + if err != nil { + return nil, err + } + return k.ListTrash(ctx) +} + +type TrashRestoreOptions struct { + KegTargetOptions + NodeIDs []string +} + +// TrashRestore puts trashed nodes back at their ids, all or nothing. +func (t *Tap) TrashRestore(ctx context.Context, opts TrashRestoreOptions) error { + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + if err != nil { + return err + } + ids := make([]keg.NodeId, len(opts.NodeIDs)) + for i, raw := range opts.NodeIDs { + id, err := keg.ParseNode(raw) + if err != nil { + return fmt.Errorf("restore %d node %q: %w", i, raw, err) + } + ids[i] = *id + } + return k.RestoreTrashed(ctx, ids) +} diff --git a/pkg/tapper/tap_url.go b/pkg/tapper/tap_url.go new file mode 100644 index 00000000..458e1eb5 --- /dev/null +++ b/pkg/tapper/tap_url.go @@ -0,0 +1,363 @@ +package tapper + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/jlrickert/tapper/pkg/keg" +) + +// WebURL kinds: what a Hub web page shows. +const ( + WebURLKindNamespace = "namespace" + WebURLKindKeg = "keg" + WebURLKindNode = "node" + WebURLKindFlight = "flight" +) + +// WebURLOptions configures Tap.WebURL. +type WebURLOptions struct { + KegTargetOptions + + // Ref names the page: empty for the current KEG, a node id, a keg or node + // reference, a flight, a lone @namespace, or a Hub page URL. + Ref string +} + +// WebURL is the Hub web page for a reference. +type WebURL struct { + // URL is the absolute browser link. + URL string `json:"url"` + // Kind is namespace, keg, node, or flight. + Kind string `json:"kind"` + // Ref is the canonical reference the page shows: @ns, keg:@ns/keg, + // keg:@ns/keg/42, or @ns/+slug. + Ref string `json:"ref"` +} + +// webSegment is the Hub's route segment shape. Every namespace, keg, and +// flight segment that reaches a path matches it, so paths need no escaping. +var webSegment = regexp.MustCompile(`^[a-z0-9_-]+$`) + +// webNodeID is a note page's plain decimal node id. +var webNodeID = regexp.MustCompile(`^[0-9]+$`) + +const webURLForms = "use a node id (42), a KEG (keg:alias, keg:~alias, @ns/keg), " + + "a note (keg:alias/42, keg:~alias/42, @ns/keg/42), a flight (+slug, @ns/+slug), " + + "a namespace (@ns), or a Hub page URL" + +// IsFlightRef reports whether raw is spelled as a flight reference: a bare +// +slug or @namespace/+slug. +func IsFlightRef(raw string) bool { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "+") { + return true + } + if rest, ok := strings.CutPrefix(raw, "@"); ok { + _, slug, found := strings.Cut(rest, "/") + return found && strings.HasPrefix(slug, "+") + } + return false +} + +// WebURL returns the Hub web page for a KEG, note, flight, or namespace. It +// builds the link from configuration alone where it can: only a keg-local +// ~alias reads the current KEG's settings. The page shapes are the Hub's +// browser routes (see "Hub Web Links" in docs/architecture/configuration-resolution.md): +// +// /@ns namespace +// /@ns/keg KEG +// /@ns/keg/42 note +// /@ns/+slug flight +func (t *Tap) WebURL(ctx context.Context, opts WebURLOptions) (WebURL, error) { + raw := strings.TrimSpace(opts.Ref) + switch { + case raw == "": + return t.webKegURL(ctx, opts.KegTargetOptions, "") + case strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://"): + return t.webPageURL(opts.KegTargetOptions, raw) + case IsFlightRef(raw): + return t.webFlightURL(opts.KegTargetOptions, raw) + case strings.HasPrefix(raw, "@") && !strings.Contains(raw, "/"): + ns := strings.TrimPrefix(raw, "@") + if !webSegment.MatchString(ns) { + return WebURL{}, fmt.Errorf("invalid namespace %q: %s: %w", raw, webURLForms, keg.ErrInvalid) + } + base, err := t.webBaseURL(opts.KegTargetOptions) + if err != nil { + return WebURL{}, err + } + return WebURL{URL: base + "/@" + ns, Kind: WebURLKindNamespace, Ref: "@" + ns}, nil + } + + nodeRaw := raw + if strings.HasPrefix(raw, "@") && strings.Count(raw, "/") == 2 { + nodeRaw = "keg:" + raw + } + if ref, err := keg.ParseNodeRef(nodeRaw); err == nil { + return t.webNodeURL(ctx, opts.KegTargetOptions, ref) + } + + if alias, ok := strings.CutPrefix(raw, "keg:~"); ok { + return t.webSettingsAliasURL(ctx, opts.KegTargetOptions, alias) + } + ref := parseKegRef(raw) + if ValidateKegAlias(ref.Name) != nil || (ref.Namespace != "" && !webSegment.MatchString(ref.Namespace)) { + return WebURL{}, fmt.Errorf("unrecognized reference %q: %s: %w", raw, webURLForms, keg.ErrInvalid) + } + return t.webKegURL(ctx, opts.KegTargetOptions, kegRefSelector(ref)) +} + +// kegRefSelector renders a parsed keg reference as the selector every resolver +// understands: @ns/name, or a bare name. +func kegRefSelector(ref KegRef) string { + if ref.Namespace != "" { + return "@" + strings.TrimPrefix(ref.Namespace, "@") + "/" + ref.Name + } + return ref.Name +} + +func (t *Tap) webKegURL(ctx context.Context, opts KegTargetOptions, selector string) (WebURL, error) { + base, ns, name, err := t.webKeg(ctx, opts, selector) + if err != nil { + return WebURL{}, err + } + return webKegPage(base, ns, name), nil +} + +func webKegPage(base, ns, name string) WebURL { + return WebURL{URL: base + "/@" + ns + "/" + name, Kind: WebURLKindKeg, Ref: "keg:@" + ns + "/" + name} +} + +func (t *Tap) webNodeURL(ctx context.Context, opts KegTargetOptions, ref *keg.NodeRef) (WebURL, error) { + var base, ns, name string + var err error + switch ref.Form { + case keg.RefLocal: + base, ns, name, err = t.webKeg(ctx, opts, "") + case keg.RefAlias: + base, ns, name, err = t.webKeg(ctx, opts, ref.Alias) + case keg.RefQualified: + opts.Namespace = "" + base, ns, name, err = t.webKeg(ctx, opts, "@"+ref.Namespace+"/"+ref.KegName) + case keg.RefSettingsAlias: + base, ns, name, err = t.webSettingsAliasKeg(ctx, opts, ref.Alias) + default: + err = fmt.Errorf("unknown node ref form") + } + if err != nil { + return WebURL{}, err + } + id := strconv.Itoa(ref.Node.ID) + return WebURL{ + URL: base + "/@" + ns + "/" + name + "/" + id, + Kind: WebURLKindNode, + Ref: "keg:@" + ns + "/" + name + "/" + id, + }, nil +} + +func (t *Tap) webSettingsAliasURL(ctx context.Context, opts KegTargetOptions, alias string) (WebURL, error) { + base, ns, name, err := t.webSettingsAliasKeg(ctx, opts, alias) + if err != nil { + return WebURL{}, err + } + return webKegPage(base, ns, name), nil +} + +// webSettingsAliasKeg resolves a keg-local ~alias against the current KEG's +// settings, the one form that reads the network. +func (t *Tap) webSettingsAliasKeg(ctx context.Context, opts KegTargetOptions, alias string) (base, ns, name string, err error) { + alias = strings.TrimSpace(alias) + if !webSegment.MatchString(alias) { + return "", "", "", fmt.Errorf("invalid settings alias %q: %s: %w", "~"+alias, webURLForms, keg.ErrInvalid) + } + current, err := t.resolveKeg(ctx, opts) + if err != nil { + return "", "", "", err + } + settings, err := current.Settings(ctx) + if err != nil { + return "", "", "", err + } + ns, name, err = keg.ResolveSettingsAlias(settings.Aliases, alias) + if err != nil { + return "", "", "", err + } + opts.Namespace = "" + if target := current.Target(); target != nil && strings.TrimSpace(target.Hub) != "" && t.KegResolver == nil { + opts.Hub = target.Hub + } + return t.webKeg(ctx, opts, "@"+ns+"/"+name) +} + +// webKeg resolves a KEG selector (empty for the current KEG) to its Hub base +// URL, namespace, and name. A Hub serving its own MCP resolves through its +// injected KegResolver; the CLI resolves from configuration without opening +// the KEG. +func (t *Tap) webKeg(ctx context.Context, opts KegTargetOptions, selector string) (base, ns, name string, err error) { + if selector != "" { + opts.Keg = selector + } + var target *keg.Target + if t.KegResolver != nil { + if base, err = t.webBaseURL(opts); err != nil { + return "", "", "", err + } + k, rerr := t.resolveKeg(ctx, opts) + if rerr != nil { + return "", "", "", rerr + } + target = k.Target() + } else { + target, err = t.ConfigService.ResolveTarget(opts.Keg, opts.Namespace, opts.Hub) + if err != nil { + if sel := t.webKegSelector(opts); strings.HasPrefix(sel, "http://") || strings.HasPrefix(sel, "https://") { + return "", "", "", fmt.Errorf("keg %q has no Hub web page: %w", sel, err) + } + return "", "", "", err + } + if target != nil && strings.TrimSpace(target.HubURL) != "" { + base = strings.TrimRight(CanonicalConfiguredHubURL(target.HubURL), "/") + } + } + if target == nil || base == "" || target.Namespace == "" || target.KegName == "" { + label := t.webKegSelector(opts) + if target != nil && target.Url != "" { + label = target.Url + } + return "", "", "", fmt.Errorf("keg %q has no Hub web page", label) + } + if !webSegment.MatchString(target.Namespace) || !webSegment.MatchString(target.KegName) { + return "", "", "", fmt.Errorf("keg @%s/%s has no Hub web page address", target.Namespace, target.KegName) + } + return base, target.Namespace, target.KegName, nil +} + +// webKegSelector is the keg selector ResolveTarget reads: the explicit one, +// else the configured keg. +func (t *Tap) webKegSelector(opts KegTargetOptions) string { + if sel := strings.TrimSpace(opts.Keg); sel != "" { + return sel + } + if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { + return strings.TrimSpace(cfg.Keg()) + } + return "" +} + +func (t *Tap) webFlightURL(opts KegTargetOptions, raw string) (WebURL, error) { + defaultNS := "" + if t.KegResolver == nil { + if cfg, err := t.ConfigService.Config(); err == nil { + defaultNS = t.defaultFlightNamespace(cfg) + } + } + if defaultNS == "" { + defaultNS = strings.TrimPrefix(strings.TrimSpace(opts.Namespace), "@") + } + ref, err := ParseFlightRef(raw, defaultNS) + if err != nil { + return WebURL{}, err + } + if ref.Namespace == "" { + return WebURL{}, fmt.Errorf("flight %q needs a namespace; use @namespace/+slug: %w", raw, keg.ErrInvalid) + } + if !webSegment.MatchString(ref.Namespace) || !webSegment.MatchString(ref.Slug) { + return WebURL{}, fmt.Errorf("invalid flight reference %q: %w", raw, keg.ErrInvalid) + } + base, err := t.webBaseURL(opts) + if err != nil { + return WebURL{}, err + } + canonical := ref.Canonical() + return WebURL{URL: base + "/" + canonical, Kind: WebURLKindFlight, Ref: canonical}, nil +} + +// webPageURL validates a Hub page URL: it must be on the Hub this context +// resolves to and name one of the four page shapes. It is returned unchanged. +func (t *Tap) webPageURL(opts KegTargetOptions, raw string) (WebURL, error) { + base, err := t.webBaseURL(opts) + if err != nil { + return WebURL{}, err + } + if err := ValidateBrowserURL(raw, base); err != nil { + return WebURL{}, fmt.Errorf("%q is not a page on the Hub at %s: %w", raw, base, keg.ErrInvalid) + } + u, err := url.Parse(raw) + if err != nil { + return WebURL{}, err + } + basePath := "" + if b, err := url.Parse(base); err == nil { + basePath = strings.TrimRight(b.Path, "/") + } + path, ok := strings.CutPrefix(u.Path, basePath+"/") + if !ok { + return WebURL{}, fmt.Errorf("%q is not a page on the Hub at %s: %w", raw, base, keg.ErrInvalid) + } + kind, ref, ok := classifyWebPath(strings.TrimSuffix(path, "/")) + if !ok { + return WebURL{}, fmt.Errorf("%q is not a namespace, KEG, note, or flight page: %w", raw, keg.ErrInvalid) + } + return WebURL{URL: raw, Kind: kind, Ref: ref}, nil +} + +// classifyWebPath reads a Hub page path (without its leading slash) back into +// its kind and canonical reference. +func classifyWebPath(path string) (kind, ref string, ok bool) { + rest, found := strings.CutPrefix(path, "@") + if !found { + return "", "", false + } + parts := strings.Split(rest, "/") + if !webSegment.MatchString(parts[0]) { + return "", "", false + } + ns := parts[0] + switch len(parts) { + case 1: + return WebURLKindNamespace, "@" + ns, true + case 2: + if slug, isFlight := strings.CutPrefix(parts[1], "+"); isFlight { + if !webSegment.MatchString(slug) { + return "", "", false + } + return WebURLKindFlight, "@" + ns + "/+" + slug, true + } + if !webSegment.MatchString(parts[1]) { + return "", "", false + } + return WebURLKindKeg, "keg:@" + ns + "/" + parts[1], true + case 3: + if !webNodeID.MatchString(parts[2]) || !webSegment.MatchString(parts[1]) { + return "", "", false + } + id, err := strconv.Atoi(parts[2]) + if err != nil { + return "", "", false + } + return WebURLKindNode, "keg:@" + ns + "/" + parts[1] + "/" + strconv.Itoa(id), true + } + return "", "", false +} + +// webBaseURL returns the Hub's web origin: the hub serving this Tap when one +// is injected, otherwise the selected configured hub. +func (t *Tap) webBaseURL(opts KegTargetOptions) (string, error) { + if t.KegResolver != nil { + base := strings.TrimRight(strings.TrimSpace(t.WebBaseURL), "/") + if base == "" { + return "", fmt.Errorf("this Hub has no public web address configured") + } + return base, nil + } + _, entry, err := t.ConfigService.SelectedHub(strings.TrimSpace(opts.Hub)) + if err != nil { + return "", err + } + return strings.TrimRight(CanonicalConfiguredHubURL(entry.URL), "/"), nil +} diff --git a/pkg/tapper/tap_url_test.go b/pkg/tapper/tap_url_test.go new file mode 100644 index 00000000..27bfab32 --- /dev/null +++ b/pkg/tapper/tap_url_test.go @@ -0,0 +1,189 @@ +package tapper + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/internal/testapi" + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func newWebURLTap(t *testing.T, config string) *Tap { + t.Helper() + sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) + rt := sb.Runtime() + tap, err := NewTap(TapOptions{Runtime: rt, Root: "/workspace"}) + require.NoError(t, err) + require.NoError(t, rt.AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + tap.ConfigService.Reload() + return tap +} + +func TestWebURL_EveryRefForm(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example/', token: tok}\n") + ctx := context.Background() + + cases := []struct { + ref, url, kind, canonical string + }{ + {"", "https://hub.example/@team/notes", WebURLKindKeg, "keg:@team/notes"}, + {"42", "https://hub.example/@team/notes/42", WebURLKindNode, "keg:@team/notes/42"}, + {"42-1234", "https://hub.example/@team/notes/42", WebURLKindNode, "keg:@team/notes/42"}, + {"keg:@ops/runbooks", "https://hub.example/@ops/runbooks", WebURLKindKeg, "keg:@ops/runbooks"}, + {"@ops/runbooks", "https://hub.example/@ops/runbooks", WebURLKindKeg, "keg:@ops/runbooks"}, + {"keg:@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"+dev", "https://hub.example/@team/+dev", WebURLKindFlight, "@team/+dev"}, + {"@ops/+oncall", "https://hub.example/@ops/+oncall", WebURLKindFlight, "@ops/+oncall"}, + {"@ops", "https://hub.example/@ops", WebURLKindNamespace, "@ops"}, + {"https://hub.example/@ops/runbooks/7", "https://hub.example/@ops/runbooks/7", WebURLKindNode, "keg:@ops/runbooks/7"}, + {"https://hub.example/@ops/+oncall", "https://hub.example/@ops/+oncall", WebURLKindFlight, "@ops/+oncall"}, + {"https://hub.example/@ops", "https://hub.example/@ops", WebURLKindNamespace, "@ops"}, + {"https://hub.example/@ops/runbooks/", "https://hub.example/@ops/runbooks/", WebURLKindKeg, "keg:@ops/runbooks"}, + {"https://hub.example/@ops/runbooks/007", "https://hub.example/@ops/runbooks/007", WebURLKindNode, "keg:@ops/runbooks/7"}, + } + for _, tc := range cases { + t.Run(tc.ref, func(t *testing.T) { + got, err := tap.WebURL(ctx, WebURLOptions{Ref: tc.ref}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: tc.url, Kind: tc.kind, Ref: tc.canonical}, got) + }) + } +} + +func TestWebURL_KegTargetOptionsSelectTheCurrentKeg(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + ctx := context.Background() + + got, err := tap.WebURL(ctx, WebURLOptions{Ref: "5", KegTargetOptions: KegTargetOptions{Keg: "@ops/runbooks"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@ops/runbooks/5", got.URL) + + got, err = tap.WebURL(ctx, WebURLOptions{KegTargetOptions: KegTargetOptions{Keg: "journal", Namespace: "ops"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@ops/journal", got.URL) + + // A bare alias resolves in --namespace, as it does for every command. + for ref, want := range map[string]string{ + "journal": "https://hub.example/@ops/journal", + "keg:journal": "https://hub.example/@ops/journal", + "keg:journal/3": "https://hub.example/@ops/journal/3", + } { + got, err = tap.WebURL(ctx, WebURLOptions{Ref: ref, KegTargetOptions: KegTargetOptions{Namespace: "ops"}}) + require.NoError(t, err, ref) + require.Equal(t, want, got.URL, ref) + } + _, err = tap.WebURL(ctx, WebURLOptions{Ref: "journal"}) + require.ErrorContains(t, err, "namespace is required") + + // A qualified reference names its own namespace; --namespace only fills a + // bare one. + got, err = tap.WebURL(ctx, WebURLOptions{Ref: "@team/notes/1", KegTargetOptions: KegTargetOptions{Namespace: "ops"}}) + require.NoError(t, err) + require.Equal(t, "https://hub.example/@team/notes/1", got.URL) +} + +func TestWebURL_Errors(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + ctx := context.Background() + + cases := []struct { + name string + opts WebURLOptions + want string + }{ + {"raw endpoint keg", WebURLOptions{KegTargetOptions: KegTargetOptions{Keg: "https://raw.example/keg"}}, "has no Hub web page"}, + {"malformed ref", WebURLOptions{Ref: "Not A Keg!"}, "unrecognized reference"}, + {"malformed namespace", WebURLOptions{Ref: "@Bad"}, "invalid namespace"}, + {"url on another host", WebURLOptions{Ref: "https://elsewhere.example/@team/notes"}, "is not a page on the Hub"}, + {"url that is not a page", WebURLOptions{Ref: "https://hub.example/settings"}, "is not a namespace, KEG, note, or flight page"}, + {"unknown hub", WebURLOptions{Ref: "@ops", KegTargetOptions: KegTargetOptions{Hub: "missing"}}, "missing"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := tap.WebURL(ctx, tc.opts) + require.Error(t, err) + require.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestWebURL_FlightNeedsANamespace(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "hub: cloud\nkeg: notes\nhubs:\n cloud: {url: 'https://hub.example', token: tok}\n") + _, err := tap.WebURL(context.Background(), WebURLOptions{Ref: "+dev"}) + require.ErrorContains(t, err, "needs a namespace") +} + +// A keg-local ~alias is the one form that reads the network: the current +// keg's settings name the keg it points at. +func TestWebURL_SettingsAliasReadsCurrentKegSettings(t *testing.T) { + t.Parallel() + hub := testapi.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/@team/kegs/notes/settings" { + _, _ = w.Write([]byte("kegv: 2025-07\naliases:\n - alias: lib\n url: keg:@ops/library\n")) + return + } + http.NotFound(w, r) + })) + defer hub.Close() + tap := newWebURLTap(t, "hub: cloud\nkeg: '@team/notes'\nhubs:\n cloud: {url: '"+hub.URL+"', token: tok}\n") + ctx := context.Background() + + got, err := tap.WebURL(ctx, WebURLOptions{Ref: "keg:~lib/12"}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: hub.URL + "/@ops/library/12", Kind: WebURLKindNode, Ref: "keg:@ops/library/12"}, got) + + got, err = tap.WebURL(ctx, WebURLOptions{Ref: "keg:~lib"}) + require.NoError(t, err) + require.Equal(t, WebURL{URL: hub.URL + "/@ops/library", Kind: WebURLKindKeg, Ref: "keg:@ops/library"}, got) + + _, err = tap.WebURL(ctx, WebURLOptions{Ref: "keg:~missing/1"}) + require.ErrorIs(t, err, keg.ErrNotExist) +} + +// A Hub serving its own MCP resolves kegs through its injected resolver and +// links to its own web address. +func TestWebURL_HostedUsesTheServingHub(t *testing.T) { + t.Parallel() + tap := newWebURLTap(t, "") + tap.KegResolver = func(_ context.Context, opts KegTargetOptions, _ FlightRole) (keg.Keg, error) { + ns, name := opts.Namespace, opts.Keg + if head, tail, ok := strings.Cut(strings.TrimPrefix(opts.Keg, "@"), "/"); ok && strings.HasPrefix(opts.Keg, "@") { + ns, name = head, tail + } + if name == "" { + name = "notes" + } + k := keg.NewLocalKeg(nil, tap.Runtime) + k.SetTarget(&keg.Target{Namespace: ns, KegName: name}) + return k, nil + } + ctx := context.Background() + opts := KegTargetOptions{Namespace: "jared"} + + _, err := tap.WebURL(ctx, WebURLOptions{Ref: "@ops", KegTargetOptions: opts}) + require.ErrorContains(t, err, "no public web address") + + tap.WebBaseURL = "https://atlas.example/" + for ref, want := range map[string]string{ + "": "https://atlas.example/@jared/notes", + "4": "https://atlas.example/@jared/notes/4", + "journal": "https://atlas.example/@jared/journal", + "@ops/runbooks/7": "https://atlas.example/@ops/runbooks/7", + "+dev": "https://atlas.example/@jared/+dev", + "@ops": "https://atlas.example/@ops", + "https://atlas.example/@ops/runbooks": "https://atlas.example/@ops/runbooks", + } { + got, err := tap.WebURL(ctx, WebURLOptions{Ref: ref, KegTargetOptions: opts}) + require.NoError(t, err, ref) + require.Equal(t, want, got.URL, ref) + } +} diff --git a/pkg/tapper/tap_use.go b/pkg/tapper/tap_use.go index b7749514..fee9366d 100644 --- a/pkg/tapper/tap_use.go +++ b/pkg/tapper/tap_use.go @@ -84,36 +84,51 @@ func (t *Tap) Use(ctx context.Context, opts UseOptions) error { // UseStatus returns a YAML summary of the resolved KEG, Hub, and flight // defaults and the source that set each, for `tap use` with no args. func (t *Tap) UseStatus(_ context.Context, opts KegTargetOptions) (string, error) { - type slot struct { - Value string `yaml:"value,omitempty"` - Scope string `yaml:"scope,omitempty"` - } type status struct { Resolved resolvedIdentity `yaml:"resolved"` - Hub slot `yaml:"hub"` - Keg slot `yaml:"keg"` - Flight slot `yaml:"flight"` + Hub ConfigSlot `yaml:"hub"` + Keg ConfigSlot `yaml:"keg"` + Flight ConfigSlot `yaml:"flight"` } - out := status{Resolved: t.resolveIdentity(opts)} - if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { - out.Hub = slot{Value: cfg.resolveHubName(), Scope: t.configFieldScope("hub")} - out.Keg = slot{Value: cfg.Keg(), Scope: t.configFieldScope("keg")} - out.Flight = slot{Value: cfg.Flight(), Scope: t.configFieldScope("flight")} + slots := t.configSlots(opts) + out := status{Resolved: t.resolveIdentity(opts), Hub: slots.Hub, Keg: slots.Keg, Flight: slots.Flight} + b, err := yaml.Marshal(out) + if err != nil { + return "", fmt.Errorf("unable to marshal use status: %w", err) } + return string(b), nil +} + +// ConfigSlot is one configured default and the config tier that set it +// (flag, env, project, kegMap, or user). +type ConfigSlot struct { + Value string `yaml:"value,omitempty" json:"value,omitempty"` + Scope string `yaml:"scope,omitempty" json:"scope,omitempty"` +} +type configSlots struct { + Hub, Keg, Flight ConfigSlot +} + +// configSlots resolves the hub, keg, and flight defaults and where each came +// from, with explicit flags winning. `tap use` and `tap info` share it so they +// never disagree about the CLI's context. +func (t *Tap) configSlots(opts KegTargetOptions) configSlots { + var out configSlots + if cfg, err := t.ConfigService.Config(); err == nil && cfg != nil { + out.Hub = ConfigSlot{Value: cfg.resolveHubName(), Scope: t.configFieldScope("hub")} + out.Keg = ConfigSlot{Value: cfg.Keg(), Scope: t.configFieldScope("keg")} + out.Flight = ConfigSlot{Value: cfg.Flight(), Scope: t.configFieldScope("flight")} + } if opts.Keg != "" { - out.Keg = slot{Value: opts.Keg, Scope: "flag"} + out.Keg = ConfigSlot{Value: opts.Keg, Scope: "flag"} } if opts.Hub != "" { - out.Hub = slot{Value: opts.Hub, Scope: "flag"} + out.Hub = ConfigSlot{Value: opts.Hub, Scope: "flag"} } if opts.Flight != "" { - out.Flight = slot{Value: opts.Flight, Scope: "flag"} + out.Flight = ConfigSlot{Value: opts.Flight, Scope: "flag"} } - b, err := yaml.Marshal(out) - if err != nil { - return "", fmt.Errorf("unable to marshal use status: %w", err) - } - return string(b), nil + return out } diff --git a/pkg/tapper/tool_categories.go b/pkg/tapper/tool_categories.go new file mode 100644 index 00000000..e4b84051 --- /dev/null +++ b/pkg/tapper/tool_categories.go @@ -0,0 +1,69 @@ +package tapper + +// ToolCategory sorts every agent tool by the job it does in the KEG system, +// so an agent can tell everyday memory work from browsing the public +// directory and from administering the memory system itself. Each tool's +// description opens with its category label, orientation prints the intent +// of each category, and the Hub's tool groups use the same split. +type ToolCategory string + +const ( + // ToolCategorySession tools report who the session is and what it may do. + ToolCategorySession ToolCategory = "Session" + // ToolCategoryMemory tools read and write notes in the covered KEGs. + ToolCategoryMemory ToolCategory = "Memory" + // ToolCategoryExplore tools search the hub's public directory. + ToolCategoryExplore ToolCategory = "Explore" + // ToolCategoryManage tools change the structure of the memory system. + ToolCategoryManage ToolCategory = "Manage" +) + +// ToolCategoryInfo is one category and the one-line intent an agent reads. +type ToolCategoryInfo struct { + Category ToolCategory + Intent string +} + +// ToolCategories lists the categories in the order agents see them. +var ToolCategories = []ToolCategoryInfo{ + {ToolCategorySession, "who you are and what this session may do; always available."}, + {ToolCategoryMemory, "read and write notes in the KEGs this session covers. Your own KEGs, flights, schemas, and tags are listed, never searched; node_search finds notes."}, + {ToolCategoryExplore, "search the hub's public directory of KEGs, flights, and people. Read-only, and a result never grants access."}, + {ToolCategoryManage, "change the memory system itself: create and delete KEGs, edit settings and schemas, repair indexes and locks, and manage flights and agents. Writing a note never needs these."}, +} + +var toolCategoryOf = map[string]ToolCategory{ + "orient": ToolCategorySession, "guide": ToolCategorySession, + "session_info": ToolCategorySession, "session_refresh": ToolCategorySession, + "namespace_list": ToolCategorySession, "invitation_list": ToolCategorySession, + + "keg_list": ToolCategoryMemory, "keg_info": ToolCategoryMemory, "keg_settings_read": ToolCategoryMemory, + "node_read": ToolCategoryMemory, "node_list": ToolCategoryMemory, "node_search": ToolCategoryMemory, + "node_grep": ToolCategoryMemory, "node_links": ToolCategoryMemory, "node_backlinks": ToolCategoryMemory, + "node_stats": ToolCategoryMemory, "node_create": ToolCategoryMemory, "node_edit": ToolCategoryMemory, + "node_move": ToolCategoryMemory, "node_delete": ToolCategoryMemory, "node_merge": ToolCategoryMemory, + "tag_list": ToolCategoryMemory, "schema_list": ToolCategoryMemory, "schema_read": ToolCategoryMemory, + "schema_validate": ToolCategoryMemory, "index_list": ToolCategoryMemory, "index_read": ToolCategoryMemory, + "file_list": ToolCategoryMemory, "file_download": ToolCategoryMemory, "file_upload": ToolCategoryMemory, + "file_delete": ToolCategoryMemory, "image_list": ToolCategoryMemory, "image_download": ToolCategoryMemory, + "image_upload": ToolCategoryMemory, "image_delete": ToolCategoryMemory, "snapshot_list": ToolCategoryMemory, + "snapshot_read": ToolCategoryMemory, "snapshot_create": ToolCategoryMemory, "snapshot_restore": ToolCategoryMemory, + "trash_list": ToolCategoryMemory, "trash_restore": ToolCategoryMemory, "lock_acquire": ToolCategoryMemory, + "lock_release": ToolCategoryMemory, "lock_status": ToolCategoryMemory, + "flight_list": ToolCategoryMemory, "flight_read": ToolCategoryMemory, "url": ToolCategoryMemory, + + "explore_kegs": ToolCategoryExplore, "explore_flights": ToolCategoryExplore, "explore_people": ToolCategoryExplore, + + "keg_create": ToolCategoryManage, "keg_delete": ToolCategoryManage, "keg_settings_edit": ToolCategoryManage, + "keg_replace": ToolCategoryManage, "schema_create": ToolCategoryManage, "schema_edit": ToolCategoryManage, + "schema_delete": ToolCategoryManage, "index_rebuild": ToolCategoryManage, "keg_check": ToolCategoryManage, + "lock_force_release": ToolCategoryManage, "flight_create": ToolCategoryManage, "flight_edit": ToolCategoryManage, + "flight_delete": ToolCategoryManage, "agent_list": ToolCategoryManage, "agent_read": ToolCategoryManage, + "agent_create": ToolCategoryManage, "agent_edit": ToolCategoryManage, "agent_delete": ToolCategoryManage, +} + +// ToolCategoryOf returns the category of an agent tool. +func ToolCategoryOf(tool string) (ToolCategory, bool) { + c, ok := toolCategoryOf[tool] + return c, ok +} diff --git a/schemas/flight-manifest.json b/schemas/flight-manifest.json index c19c92c3..5c0b5f7b 100644 --- a/schemas/flight-manifest.json +++ b/schemas/flight-manifest.json @@ -34,8 +34,9 @@ }, "role": { "type": "string", - "description": "Maximum role granted by this cover entry. Omitted roles default to viewer.", + "description": "Maximum role granted by this cover entry. Omitted roles default to viewer. none blocks the KEG: it is out of the cover however notes link to it, and the cover does not follow its links.", "enum": [ + "none", "viewer", "editor", "admin" diff --git a/schemas/keg-settings.json b/schemas/keg-settings.json index 6a2463d1..7e492ac7 100644 --- a/schemas/keg-settings.json +++ b/schemas/keg-settings.json @@ -37,9 +37,9 @@ "type": "string", "description": "Markdown guidance for agents working in this keg." }, - "links": { + "aliases": { "type": "array", - "description": "Named external links or related keg references.", + "description": "Named KEGs and external URLs. A note links keg:~alias/N to reach the KEG an alias names, so the link survives the KEG moving: only the alias changes.", "items": { "type": "object", "description": "A single named link entry.", @@ -60,6 +60,14 @@ "additionalProperties": true } }, + "links": { + "type": "array", + "deprecated": true, + "description": "Legacy name for aliases, read only when aliases is absent.", + "items": { + "$ref": "#/properties/aliases/items" + } + }, "listFields": { "type": "array", "description": "Field selectors shown by default in node listings, used by both `tap node list` and the hosted node list. A bare word names a metadata key (\"type\", \"subkind\"); a leading dot names a statistics field (\".omega\"); \"id\", \"title\", and \"tags\" are reserved.",