From c7310a532c793dc665c69b5dd924b165a19f1939 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Sun, 30 Aug 2026 19:23:17 -0500 Subject: [PATCH 1/2] feat(keg): make Tapper remote-only Tapper carried two storage worlds. A local keg resolved to a filesystem repository on disk; a remote keg resolved to the Hub's operation API. The two paths diverged constantly -- locking, snapshots, dex maintenance, and schema validation each had to be implemented and tested twice -- and the local path was the one that could not enforce authority, because there is no ACL on a directory. Settle on the Hub. `keg.Keg` now has exactly one production shape: `RemoteKeg` for clients, `LocalKeg` over the Hub's server-side `PgRepo` for the Hub itself. The filesystem repository and its lock, snapshot, and event backends are gone, along with the atomic-write and operation scaffolding that existed only to serve them. Tests move to a purpose-built memory repository under internal/testkegrepo, which is test-only by construction and cannot be reached from a released binary. With one backend, several surfaces collapse: - The pruned `keg` binary loses its reason to exist -- it existed to offer project-local resolution -- so `tap` becomes the single entrypoint and the goreleaser build, Homebrew formula, and install task go with it. - Configuration and flight manifests become Hub-resolved documents rather than files discovered on disk. - The graph visualization surface is removed: the Bun/TypeScript frontend, its embedded bundle, and the Keg and Tap graph operations. Dex links, backlinks, and RelatedNodes remain the supported relationship surface. - `config` is renamed to `settings` throughout, matching what the document has always been. The same consolidation makes authority enforceable, so this commit also lands the behavior that depends on it: - Protected writes require read-derived precondition tokens. Settings, schemas, node updates, moves, and removals now carry hashes with actionable conflict recovery instead of last-writer-wins. - Launch roots are immutable and pinned per connection, with per-call flight selection across the pinned root's recursive accessible graph. A subflight is an ordered list entry on its parent, not an assertion about the whole graph, so cycles are tolerated by deduplication rather than rejected by a write-time walk. - Keg settings and schema writes require admin. They steer every agent on the keg, so editor is not sufficient. - `tap launch` no longer requires a configured flight. Bootstrapping a first flight needs an agent session, and that session needed a flight that did not exist yet; a flightless launch runs under identity authority and warns which access is in play. - JSON schemas are embedded and materialized so editors can drive completion and validation against the running build. --- .goreleaser.yaml | 30 - Taskfile.yml | 10 +- bun.lock | 45 - cmd/keg/LICENSE | 201 --- cmd/keg/keg.go | 39 - cmd/render-integrations/main.go | 2 +- frontend/graph/README.md | 9 - frontend/graph/package.json | 13 - frontend/graph/src/main.ts | 234 ---- frontend/graph/tsconfig.json | 11 - integrations/content/agent-orient.md | 96 +- integrations/content/linking.md | 16 +- integrations/content/tool-inventory.md | 50 +- .../tapper-dev/.claude-plugin/plugin.json | 2 +- .../claude/tapper/.claude-plugin/plugin.json | 2 +- .../rendered/claude/tapper/hooks/hooks.json | 2 +- .../claude/tapper/skills/tapper/SKILL.md | 162 ++- .../tapper-dev/.codex-plugin/plugin.json | 2 +- .../codex/tapper/.codex-plugin/plugin.json | 2 +- .../rendered/codex/tapper/hooks/hooks.json | 2 +- .../codex/tapper/skills/tapper/SKILL.md | 162 ++- internal/testkegrepo/memory_repository.go | 1079 ++++++++++++++++ package.json | 18 - pkg/cli/assets.go | 8 - pkg/cli/assets/graph.bundle.js | 324 ----- pkg/cli/auth_prompt.go | 7 +- pkg/cli/auth_prompt_test.go | 2 +- pkg/cli/bootstrap_prompt.go | 3 +- pkg/cli/cli.go | 7 + pkg/cli/cmd_bootstrap.go | 52 +- pkg/cli/cmd_bootstrap_test.go | 130 +- pkg/cli/cmd_cat_test.go | 102 +- pkg/cli/cmd_config_test.go | 77 +- pkg/cli/cmd_create_test.go | 20 +- pkg/cli/cmd_doctor.go | 2 +- pkg/cli/cmd_edit_test.go | 33 +- pkg/cli/cmd_file_test.go | 8 +- pkg/cli/cmd_flight.go | 15 +- pkg/cli/cmd_flight_test.go | 4 +- pkg/cli/cmd_graph.go | 67 - pkg/cli/cmd_graph_test.go | 170 --- pkg/cli/cmd_hook.go | 205 ++- pkg/cli/cmd_hook_test.go | 45 +- pkg/cli/cmd_image_test.go | 8 +- pkg/cli/cmd_import_test.go | 8 +- pkg/cli/cmd_index_test.go | 378 ------ pkg/cli/cmd_init.go | 278 +--- pkg/cli/cmd_init_test.go | 400 ------ pkg/cli/cmd_integrate_completion_test.go | 8 +- pkg/cli/cmd_keg.go | 32 +- pkg/cli/cmd_keg_create_remote_test.go | 88 ++ pkg/cli/cmd_keg_info_test.go | 4 +- pkg/cli/cmd_keg_target_completion_test.go | 59 +- pkg/cli/cmd_keg_test.go | 15 +- pkg/cli/cmd_launch.go | 40 +- pkg/cli/cmd_launch_test.go | 62 +- pkg/cli/cmd_list_test.go | 4 +- pkg/cli/cmd_mcp.go | 2 +- pkg/cli/cmd_meta_test.go | 20 +- pkg/cli/cmd_mv.go | 7 + pkg/cli/cmd_mv_test.go | 22 +- pkg/cli/cmd_rm.go | 8 + pkg/cli/cmd_rm_test.go | 23 +- pkg/cli/cmd_root.go | 46 +- pkg/cli/cmd_root_flags_test.go | 32 +- pkg/cli/cmd_root_flight_test.go | 26 +- pkg/cli/cmd_root_graph_removed_test.go | 24 + pkg/cli/cmd_schema.go | 10 + pkg/cli/cmd_schema_test.go | 5 +- pkg/cli/cmd_settings_edit_test.go | 18 +- pkg/cli/cmd_settings_test.go | 39 +- pkg/cli/cmd_snapshot_test.go | 450 ------- pkg/cli/cmd_stats_test.go | 2 +- pkg/cli/cmd_use_test.go | 18 +- pkg/cli/cmd_watch.go | 5 +- pkg/cli/cmd_watch_test.go | 9 +- pkg/cli/keg_target_flags.go | 18 +- pkg/cli/profile.go | 28 +- pkg/cli/profile_resolve_test.go | 175 --- pkg/cli/testhelpers_test.go | 389 +++++- pkg/integrations/adapters/claude_test.go | 12 +- pkg/integrations/adapters/codex.go | 8 +- pkg/integrations/adapters/codex_test.go | 16 +- .../renderdata/claude/hooks/hooks.json | 2 +- .../renderdata/codex/hooks/hooks.json | 2 +- pkg/integrations/renderdata/renderdata.go | 2 +- pkg/keg/archive.go | 70 +- pkg/keg/archive_test.go | 104 +- pkg/keg/asset_name.go | 4 +- pkg/keg/asset_name_test.go | 39 +- pkg/keg/constants.go | 12 +- pkg/keg/content.go | 16 + pkg/keg/content_test.go | 33 + pkg/keg/dex.go | 34 +- pkg/keg/dex_changes_test.go | 2 +- pkg/keg/dex_concurrent_test.go | 2 +- pkg/keg/dex_test.go | 48 +- pkg/keg/errors.go | 61 +- pkg/keg/eval_query_internal_test.go | 4 +- pkg/keg/format_boundary.go | 6 +- pkg/keg/format_fields_test.go | 16 +- pkg/keg/keg.go | 59 +- pkg/keg/keg_aggregate.go | 238 ++-- pkg/keg/keg_aggregate_test.go | 72 +- pkg/keg/keg_batch.go | 9 +- pkg/keg/keg_batch_test.go | 186 ++- pkg/keg/keg_concurrent_test.go | 284 +--- pkg/keg/keg_helpers.go | 14 +- pkg/keg/keg_iface.go | 57 +- pkg/keg/keg_iface_test.go | 10 +- pkg/keg/keg_listview_batch_test.go | 8 +- pkg/keg/keg_local_config.go | 156 --- pkg/keg/keg_local_content.go | 6 +- pkg/keg/keg_local_create.go | 12 +- pkg/keg/keg_local_dex.go | 91 +- pkg/keg/keg_local_index.go | 4 +- pkg/keg/keg_local_move.go | 56 +- pkg/keg/keg_local_node.go | 2 +- pkg/keg/keg_local_settings.go | 155 +++ pkg/keg/keg_local_view.go | 13 +- pkg/keg/keg_operation_test.go | 27 +- pkg/keg/keg_query.go | 2 +- pkg/keg/keg_remote.go | 110 +- pkg/keg/keg_remote_aggregate.go | 78 +- pkg/keg/keg_remote_events.go | 30 +- pkg/keg/keg_remote_events_test.go | 69 + pkg/keg/keg_remote_test.go | 108 +- pkg/keg/{keg_config.go => keg_settings.go} | 212 +-- ...eg_config_test.go => keg_settings_test.go} | 134 +- pkg/keg/keg_snapshots_test.go | 6 +- pkg/keg/keg_test.go | 372 ++---- pkg/keg/memory_repository_test.go | 969 ++++++++++++++ pkg/keg/node_ref.go | 10 +- pkg/keg/orientation.go | 110 ++ pkg/keg/orientation_test.go | 42 + pkg/keg/precondition.go | 64 + pkg/keg/precondition_test.go | 201 +++ pkg/keg/remote_errors.go | 46 +- pkg/keg/remote_precondition_test.go | 65 + pkg/keg/render.go | 4 +- pkg/keg/render_test.go | 19 + pkg/keg/repo_atomic.go | 201 --- pkg/keg/repo_events.go | 3 +- pkg/keg/repo_events_test.go | 249 ---- pkg/keg/repo_filesystem.go | 1147 ----------------- pkg/keg/repo_filesystem_lock.go | 146 --- pkg/keg/repo_filesystem_snapshots.go | 393 ------ pkg/keg/repo_filesystem_test.go | 285 ---- pkg/keg/repo_fs_events.go | 224 ---- pkg/keg/repo_lock_test.go | 293 ----- pkg/keg/repo_memory.go | 771 ----------- pkg/keg/repo_memory_events.go | 58 - pkg/keg/repo_memory_lock.go | 126 -- pkg/keg/repo_memory_snapshots.go | 162 --- pkg/keg/repo_memory_test.go | 575 --------- pkg/keg/repo_operation.go | 200 --- pkg/keg/repo_snapshots_test.go | 73 +- pkg/keg/repository.go | 26 +- pkg/keg/schema.go | 38 +- pkg/keg/schema_selection_test.go | 41 +- pkg/keg/schema_test.go | 65 +- pkg/keg/snapshot_indexes.go | 10 + pkg/keg/snapshot_indexes_test.go | 8 +- pkg/keg/snapshot_policy.go | 35 +- pkg/keg/snapshot_policy_internal_test.go | 91 +- pkg/keg/snapshot_policy_test.go | 6 +- pkg/keg/target.go | 208 +-- pkg/keg/target_test.go | 333 ++--- pkg/keg/testhelpers_internal_test.go | 7 + pkg/keg/testhelpers_test.go | 44 +- pkg/mcp/flight_authority_validation_test.go | 222 ---- pkg/mcp/orientation_revision_test.go | 115 ++ pkg/mcp/precondition_read_test.go | 279 ++++ pkg/mcp/precondition_schema_test.go | 126 ++ pkg/mcp/providers.go | 527 +++++++- pkg/mcp/server.go | 45 +- pkg/mcp/server_test.go | 273 ++-- pkg/mcp/session_agent_flight_test.go | 58 +- pkg/mcp/session_bootstrap_test.go | 252 ++-- pkg/mcp/session_flight.go | 655 +++++++--- pkg/mcp/session_flight_test.go | 363 +++++- pkg/mcp/session_orientation_context_test.go | 57 + pkg/mcp/session_transition_test.go | 968 ++++++++++---- pkg/mcp/tools_archive.go | 37 - pkg/mcp/tools_auth.go | 2 +- pkg/mcp/tools_flight.go | 73 +- pkg/mcp/tools_flight_lock_test.go | 55 +- pkg/mcp/tools_keg.go | 116 +- pkg/mcp/tools_orient.go | 55 +- pkg/mcp/tools_orient_test.go | 12 +- pkg/mcp/tools_read.go | 52 +- pkg/mcp/tools_repo.go | 57 +- pkg/mcp/tools_resources.go | 10 +- pkg/mcp/tools_resources_node_test.go | 5 +- pkg/mcp/tools_schema.go | 33 +- pkg/mcp/tools_settings_batch_test.go | 8 +- pkg/mcp/tools_write.go | 53 +- .../data/testuser/.config/tapper/config.yaml | 9 +- .../data/testuser/kegs/flights.d/parity.yaml | 9 - pkg/parity/parity_coverage_test.go | 91 +- pkg/parity/parity_read_test.go | 17 - pkg/parity/parity_test.go | 118 +- pkg/parity/parity_write_test.go | 30 +- pkg/schemas/schemas.go | 225 ++++ pkg/schemas/schemas_test.go | 184 +++ pkg/tapper/alias.go | 14 +- pkg/tapper/auth_flow.go | 8 + pkg/tapper/auth_flow_test.go | 14 + pkg/tapper/auth_resolver.go | 6 +- pkg/tapper/auth_resolver_chain_test.go | 4 +- pkg/tapper/auth_resolver_test.go | 12 - pkg/tapper/config.go | 289 ++--- pkg/tapper/config_agent_flight_test.go | 70 +- pkg/tapper/config_document.go | 184 +++ pkg/tapper/config_env.go | 4 - pkg/tapper/config_env_test.go | 22 - pkg/tapper/config_service.go | 78 +- pkg/tapper/config_test.go | 153 ++- pkg/tapper/constants.go | 2 +- pkg/tapper/error_types.go | 56 - pkg/tapper/flight.go | 429 +++--- pkg/tapper/flight_test.go | 586 ++++----- pkg/tapper/hub_flights.go | 31 +- pkg/tapper/hub_flights_precondition_test.go | 60 + pkg/tapper/hub_flights_test.go | 4 +- pkg/tapper/hub_grants.go | 10 +- pkg/tapper/hub_kegs.go | 119 -- pkg/tapper/hub_kegs_test.go | 80 +- pkg/tapper/keg_backend.go | 29 +- pkg/tapper/keg_backend_test.go | 12 - pkg/tapper/keg_service.go | 365 +----- pkg/tapper/keg_service_resolver_test.go | 188 --- pkg/tapper/keg_service_test.go | 244 ---- pkg/tapper/node_exists.go | 17 +- pkg/tapper/node_ref_arg_test.go | 125 -- pkg/tapper/node_ref_resolve.go | 14 +- pkg/tapper/tap.go | 25 +- pkg/tapper/tap_actor_test.go | 35 - pkg/tapper/tap_batch.go | 7 +- pkg/tapper/tap_bootstrap.go | 87 +- pkg/tapper/tap_bootstrap_test.go | 95 +- pkg/tapper/tap_cat.go | 123 +- pkg/tapper/tap_cat_format_test.go | 7 +- pkg/tapper/tap_concurrent_test.go | 147 --- pkg/tapper/tap_config.go | 67 +- pkg/tapper/tap_config_test.go | 24 - pkg/tapper/tap_create_test.go | 225 ---- pkg/tapper/tap_doctor_test.go | 92 -- pkg/tapper/tap_edit.go | 43 +- pkg/tapper/tap_edit_format_test.go | 15 +- pkg/tapper/tap_flight.go | 116 +- pkg/tapper/tap_flight_edit.go | 35 +- pkg/tapper/tap_flight_edit_test.go | 6 +- pkg/tapper/tap_graph.go | 387 ------ pkg/tapper/tap_hub.go | 78 +- pkg/tapper/tap_hub_test.go | 34 +- pkg/tapper/tap_import.go | 27 +- pkg/tapper/tap_import_test.go | 12 + pkg/tapper/tap_info.go | 303 ++--- pkg/tapper/tap_init.go | 244 +--- pkg/tapper/tap_init_test.go | 20 - pkg/tapper/tap_keg.go | 10 +- pkg/tapper/tap_keg_config_edit_test.go | 166 --- pkg/tapper/tap_keg_settings_batch_test.go | 69 +- pkg/tapper/tap_keg_test.go | 9 +- pkg/tapper/tap_launch.go | 106 +- pkg/tapper/tap_launch_test.go | 143 +- pkg/tapper/tap_list.go | 4 +- pkg/tapper/tap_list_test.go | 107 -- pkg/tapper/tap_move.go | 25 +- pkg/tapper/tap_namespace.go | 6 - pkg/tapper/tap_orient.go | 500 ++++--- pkg/tapper/tap_orient_test.go | 436 +++---- pkg/tapper/tap_remove.go | 13 +- pkg/tapper/tap_schema.go | 101 +- pkg/tapper/tap_schema_test.go | 163 --- pkg/tapper/tap_shadow_reservation_test.go | 148 --- pkg/tapper/tap_use_test.go | 45 - pkg/tapper/testhelpers_test.go | 2 +- schemas/embed.go | 17 + schemas/flight-manifest.json | 11 + .../{keg-config.json => keg-settings.json} | 8 +- schemas/tap-config.json | 65 +- test-env/README.md | 43 +- test-env/Taskfile.yml | 21 +- test-env/entrypoint.sh | 9 +- test-env/fixtures/README.md | 19 - test-env/fixtures/minimal/keg | 14 - test-env/scripts/populate.sh | 107 -- tsconfig.json | 29 - 290 files changed, 12015 insertions(+), 17706 deletions(-) delete mode 100644 bun.lock delete mode 100644 cmd/keg/LICENSE delete mode 100644 cmd/keg/keg.go delete mode 100644 frontend/graph/README.md delete mode 100644 frontend/graph/package.json delete mode 100644 frontend/graph/src/main.ts delete mode 100644 frontend/graph/tsconfig.json create mode 100644 internal/testkegrepo/memory_repository.go delete mode 100644 package.json delete mode 100644 pkg/cli/assets.go delete mode 100644 pkg/cli/assets/graph.bundle.js delete mode 100644 pkg/cli/cmd_graph.go delete mode 100644 pkg/cli/cmd_graph_test.go delete mode 100644 pkg/cli/cmd_index_test.go delete mode 100644 pkg/cli/cmd_init_test.go create mode 100644 pkg/cli/cmd_keg_create_remote_test.go create mode 100644 pkg/cli/cmd_root_graph_removed_test.go delete mode 100644 pkg/cli/cmd_snapshot_test.go delete mode 100644 pkg/cli/profile_resolve_test.go delete mode 100644 pkg/keg/keg_local_config.go create mode 100644 pkg/keg/keg_local_settings.go create mode 100644 pkg/keg/keg_remote_events_test.go rename pkg/keg/{keg_config.go => keg_settings.go} (69%) rename pkg/keg/{keg_config_test.go => keg_settings_test.go} (69%) create mode 100644 pkg/keg/memory_repository_test.go create mode 100644 pkg/keg/orientation.go create mode 100644 pkg/keg/orientation_test.go create mode 100644 pkg/keg/precondition.go create mode 100644 pkg/keg/precondition_test.go create mode 100644 pkg/keg/remote_precondition_test.go delete mode 100644 pkg/keg/repo_atomic.go delete mode 100644 pkg/keg/repo_events_test.go delete mode 100644 pkg/keg/repo_filesystem.go delete mode 100644 pkg/keg/repo_filesystem_lock.go delete mode 100644 pkg/keg/repo_filesystem_snapshots.go delete mode 100644 pkg/keg/repo_filesystem_test.go delete mode 100644 pkg/keg/repo_fs_events.go delete mode 100644 pkg/keg/repo_lock_test.go delete mode 100644 pkg/keg/repo_memory.go delete mode 100644 pkg/keg/repo_memory_events.go delete mode 100644 pkg/keg/repo_memory_lock.go delete mode 100644 pkg/keg/repo_memory_snapshots.go delete mode 100644 pkg/keg/repo_memory_test.go delete mode 100644 pkg/keg/repo_operation.go create mode 100644 pkg/keg/testhelpers_internal_test.go delete mode 100644 pkg/mcp/flight_authority_validation_test.go create mode 100644 pkg/mcp/orientation_revision_test.go create mode 100644 pkg/mcp/precondition_read_test.go create mode 100644 pkg/mcp/precondition_schema_test.go create mode 100644 pkg/mcp/session_orientation_context_test.go delete mode 100644 pkg/parity/data/testuser/kegs/flights.d/parity.yaml create mode 100644 pkg/schemas/schemas.go create mode 100644 pkg/schemas/schemas_test.go create mode 100644 pkg/tapper/config_document.go delete mode 100644 pkg/tapper/error_types.go create mode 100644 pkg/tapper/hub_flights_precondition_test.go delete mode 100644 pkg/tapper/keg_service_resolver_test.go delete mode 100644 pkg/tapper/node_ref_arg_test.go delete mode 100644 pkg/tapper/tap_actor_test.go delete mode 100644 pkg/tapper/tap_concurrent_test.go delete mode 100644 pkg/tapper/tap_config_test.go delete mode 100644 pkg/tapper/tap_create_test.go delete mode 100644 pkg/tapper/tap_doctor_test.go delete mode 100644 pkg/tapper/tap_graph.go delete mode 100644 pkg/tapper/tap_keg_config_edit_test.go delete mode 100644 pkg/tapper/tap_list_test.go delete mode 100644 pkg/tapper/tap_schema_test.go delete mode 100644 pkg/tapper/tap_shadow_reservation_test.go delete mode 100644 pkg/tapper/tap_use_test.go create mode 100644 schemas/embed.go rename schemas/{keg-config.json => keg-settings.json} (96%) delete mode 100644 test-env/fixtures/README.md delete mode 100644 test-env/fixtures/minimal/keg delete mode 100755 test-env/scripts/populate.sh delete mode 100644 tsconfig.json diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 877dc450..9412fb1e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -12,13 +12,6 @@ builds: goos: [linux, darwin, windows] goarch: [amd64, arm64] ldflags: ["-s -w -X github.com/jlrickert/tapper/pkg/cli.Version={{.Version}}"] - - id: keg - binary: keg - main: ./cmd/keg - env: [CGO_ENABLED=0, GOWORK=off] - goos: [linux, darwin, windows] - goarch: [amd64, arm64] - ldflags: ["-s -w -X github.com/jlrickert/tapper/pkg/cli.Version={{.Version}}"] archives: - id: tap @@ -28,13 +21,6 @@ archives: - goos: windows formats: [zip] files: [LICENSE, README.md, CHANGELOG.md] - - id: keg - ids: [keg] - name_template: "keg_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - format_overrides: - - goos: windows - formats: [zip] - files: [LICENSE, README.md, CHANGELOG.md] brews: - ids: [tap] @@ -52,22 +38,6 @@ brews: generate_completions_from_executable(bin/"tap", "completion") test: | system "#{bin}/tap", "--version" - - ids: [keg] - name: keg - repository: - owner: jlrickert - name: homebrew-formulae - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - directory: Formula - homepage: "https://github.com/jlrickert/tapper" - description: "Project-focused KEG CLI with pruned command surface" - license: "Apache-2.0" - install: | - bin.install "keg" - generate_completions_from_executable(bin/"keg", "completion") - test: | - system "#{bin}/keg", "--version" - checksum: name_template: "checksums.txt" diff --git a/Taskfile.yml b/Taskfile.yml index 1ae93e24..a4d8d97c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,7 +8,7 @@ includes: dir: ./test-env tasks: - run: go run "./cmd/keg" {{.CLI_ARGS}} + run: go run "./cmd/tap" {{.CLI_ARGS}} test: cmds: - go test ./pkg/... {{.CLI_ARGS}} @@ -24,14 +24,6 @@ tasks: - ./internal/apidoc/**/*.go - ./pkg/**/*.go silent: true - install-keg: - desc: Install the keg CLI (go install ./cmd/keg). - cmds: - - go install ./cmd/keg - sources: - - cmd/** - - pkg/** - - docs/**/*.md install-tap: desc: Install the tap CLI (go install ./cmd/tap). cmds: diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 014141e6..00000000 --- a/bun.lock +++ /dev/null @@ -1,45 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "dependencies": { - "@sigma/node-border": "^3.0.0", - "graphology": "^0.26.0", - "graphology-layout-forceatlas2": "^0.10.1", - "sigma": "^3.0.2", - }, - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@sigma/node-border": ["@sigma/node-border@3.0.0", "", { "peerDependencies": { "sigma": ">=3.0.0-beta.17" } }, "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ=="], - - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], - - "@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="], - - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "graphology": ["graphology@0.26.0", "", { "dependencies": { "events": "^3.3.0" }, "peerDependencies": { "graphology-types": ">=0.24.0" } }, "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg=="], - - "graphology-layout-forceatlas2": ["graphology-layout-forceatlas2@0.10.1", "", { "dependencies": { "graphology-utils": "^2.1.0" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ=="], - - "graphology-types": ["graphology-types@0.24.8", "", {}, "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q=="], - - "graphology-utils": ["graphology-utils@2.5.2", "", { "peerDependencies": { "graphology-types": ">=0.23.0" } }, "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ=="], - - "sigma": ["sigma@3.0.2", "", { "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" } }, "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - } -} diff --git a/cmd/keg/LICENSE b/cmd/keg/LICENSE deleted file mode 100644 index 8411ed33..00000000 --- a/cmd/keg/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 Jared Rickert - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/cmd/keg/keg.go b/cmd/keg/keg.go deleted file mode 100644 index 6cc27a29..00000000 --- a/cmd/keg/keg.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - _ "embed" - "os" - - "github.com/jlrickert/cli-toolkit/clock" - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/cli" -) - -//go:embed LICENSE -var licenseText string - -func main() { - cli.LicenseText = licenseText - - ctx := context.Background() - // Signal handling is intentionally not registered at the entrypoint. - // Long-lived commands (e.g., serve) install their own signal handlers - // with narrower scope so that short-lived commands exit immediately - // without intercepting SIGINT. - - rt, err := toolkit.NewRuntime(toolkit.WithProcessInfo(toolkit.NewProcessInfo(clock.OsClock{}))) - if err != nil { - os.Exit(1) - } - - if exitCode, err := cli.RunWithProfile( - ctx, - rt, - os.Args[1:], - cli.KegProfile(), - ); err != nil { - os.Exit(exitCode) - } - os.Exit(0) -} diff --git a/cmd/render-integrations/main.go b/cmd/render-integrations/main.go index 2cddc2ff..d44ebb04 100644 --- a/cmd/render-integrations/main.go +++ b/cmd/render-integrations/main.go @@ -21,7 +21,7 @@ import ( _ "github.com/jlrickert/tapper/pkg/integrations/adapters" // renderdata supplies host-specific canonical-source bytes (plugin hook - // manifests today) that must NOT ship inside the cmd/tap or cmd/keg + // manifests today) that must NOT ship inside cmd/tap // binaries. Only this command imports it; the overlay below merges // it onto the markdown-only canonical tree before adapter dispatch. "github.com/jlrickert/tapper/pkg/integrations/renderdata" diff --git a/frontend/graph/README.md b/frontend/graph/README.md deleted file mode 100644 index ac1c4875..00000000 --- a/frontend/graph/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Graph Frontend - -Build the bundled graph renderer with: - -```bash -bun build frontend/graph/src/main.ts --bundle --minify --outfile pkg/cli/assets/graph.bundle.js -``` - -The generated bundle is embedded by `pkg/cli/assets.go`. diff --git a/frontend/graph/package.json b/frontend/graph/package.json deleted file mode 100644 index 3c4b56d5..00000000 --- a/frontend/graph/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "tapper-graph", - "private": true, - "type": "module", - "scripts": { - "build": "bun build src/main.ts --bundle --minify --outfile ../../pkg/cli/assets/graph.bundle.js" - }, - "dependencies": { - "graphology": "^0.26.0", - "graphology-layout-forceatlas2": "^0.10.1", - "sigma": "^3.0.2" - } -} diff --git a/frontend/graph/src/main.ts b/frontend/graph/src/main.ts deleted file mode 100644 index 4f2239e1..00000000 --- a/frontend/graph/src/main.ts +++ /dev/null @@ -1,234 +0,0 @@ -import Graph from "graphology"; -import forceAtlas2 from "graphology-layout-forceatlas2"; -import Sigma from "sigma"; - -type GraphNode = { - id: string; - label: string; - summary: string; - tags: string[]; - url: string; -}; - -type GraphEdge = { - source: string; - target: string; - type: "link" | "backlink" | string; -}; - -type GraphPayload = { - nodes: GraphNode[]; - edges: GraphEdge[]; -}; - -declare global { - interface Window { - __KEG__?: GraphPayload; - } -} - -const EMPTY_PAYLOAD: GraphPayload = { nodes: [], edges: [] }; - -function safePayload(): GraphPayload { - const raw = window.__KEG__; - if (!raw || !Array.isArray(raw.nodes) || !Array.isArray(raw.edges)) { - return EMPTY_PAYLOAD; - } - return raw; -} - -function ensurePanel(): HTMLElement { - const existing = document.getElementById("panel"); - if (existing) return existing; - - const panel = document.createElement("aside"); - panel.id = "panel"; - panel.className = "hidden"; - document.body.appendChild(panel); - return panel; -} - -function renderEmpty(container: HTMLElement): void { - container.innerHTML = ` -
-

KEG Graph

-

No nodes found in dex indexes.

-
- `; -} - -function toNodeMap(payload: GraphPayload): Map { - const out = new Map(); - for (const node of payload.nodes) { - if (!node || typeof node.id !== "string" || node.id.trim() === "") continue; - out.set(node.id, { - id: node.id, - label: node.label || node.id, - summary: node.summary || "", - tags: Array.isArray(node.tags) ? node.tags : [], - url: node.url || "", - }); - } - return out; -} - -function edgeColor(edgeType: string): string { - if (edgeType === "backlink") return "rgba(71, 85, 105, 0.42)"; - return "rgba(30, 64, 175, 0.62)"; -} - -function edgeSigmaType(edgeType: string): "arrow" | "line" { - if (edgeType === "backlink") return "line"; - return "arrow"; -} - -function init(): void { - const container = document.getElementById("app"); - if (!container) return; - - const payload = safePayload(); - if (payload.nodes.length === 0) { - renderEmpty(container); - return; - } - - const nodeMap = toNodeMap(payload); - const graph = new Graph({ multi: true, type: "directed" }); - const degree = new Map(); - - const nodes = Array.from(nodeMap.values()); - const total = nodes.length; - const radius = Math.max(20, Math.sqrt(total) * 12); - - nodes.forEach((node, index) => { - const angle = (index / Math.max(total, 1)) * Math.PI * 2; - const ring = 1 + Math.floor(index / 180); - const x = Math.cos(angle) * radius * ring * 0.25; - const y = Math.sin(angle) * radius * ring * 0.25; - - degree.set(node.id, 0); - graph.addNode(node.id, { - x, - y, - label: node.label || node.id, - size: 4, - color: "#1f5aa6", - data: node, - }); - }); - - let edgeCount = 0; - payload.edges.forEach((edge, i) => { - if (!edge || !edge.source || !edge.target) return; - - if (!graph.hasNode(edge.source)) { - graph.addNode(edge.source, { - x: 0, - y: 0, - label: edge.source, - size: 3, - color: "#64748b", - data: { id: edge.source, label: edge.source, summary: "", tags: [], url: "" }, - }); - degree.set(edge.source, degree.get(edge.source) ?? 0); - } - if (!graph.hasNode(edge.target)) { - graph.addNode(edge.target, { - x: 0, - y: 0, - label: edge.target, - size: 3, - color: "#64748b", - data: { id: edge.target, label: edge.target, summary: "", tags: [], url: "" }, - }); - degree.set(edge.target, degree.get(edge.target) ?? 0); - } - - const key = `${edge.source}->${edge.target}:${edge.type}:${i}`; - graph.addEdgeWithKey(key, edge.source, edge.target, { - color: edgeColor(edge.type), - size: edge.type === "backlink" ? 0.55 : 0.95, - type: edgeSigmaType(edge.type), - data: edge, - }); - - degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); - degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); - edgeCount++; - }); - - graph.forEachNode((nodeId) => { - const d = degree.get(nodeId) ?? 0; - graph.setNodeAttribute(nodeId, "size", 2.4 + Math.min(10, Math.sqrt(d + 1))); - }); - - if (graph.order > 1 && graph.order <= 2600 && edgeCount > 0) { - forceAtlas2.assign(graph, { - iterations: 80, - settings: forceAtlas2.inferSettings(graph), - }); - } - - const panel = ensurePanel(); - - const renderer = new Sigma(graph, container, { - renderLabels: true, - labelRenderedSizeThreshold: 9, - defaultEdgeType: "arrow", - defaultNodeColor: "#1f5aa6", - defaultEdgeColor: "rgba(30, 64, 175, 0.62)", - defaultDrawEdgeLabels: false, - enableEdgeEvents: false, - }); - - function hidePanel() { - panel.classList.add("hidden"); - panel.innerHTML = ""; - } - - function showPanel(nodeId: string) { - const attrs = graph.getNodeAttributes(nodeId) as { - data?: GraphNode; - label?: string; - }; - const nodeData = attrs.data ?? { - id: nodeId, - label: attrs.label || nodeId, - summary: "", - tags: [], - url: "", - }; - - const outDegree = graph.outDegree(nodeId); - const inDegree = graph.inDegree(nodeId); - const tags = Array.isArray(nodeData.tags) ? nodeData.tags : []; - const safeTags = tags.length > 0 ? tags.join(", ") : "none"; - const safeSummary = nodeData.summary?.trim() || "No summary available."; - - const linkBlock = - nodeData.url && nodeData.url.trim() !== "" - ? `

Open node

` - : ""; - - panel.innerHTML = ` -

${nodeData.label || nodeData.id}

-

${safeSummary}

-

ID: ${nodeData.id}

-

Tags: ${safeTags}

-

Outgoing: ${outDegree}    Incoming: ${inDegree}

- ${linkBlock} - `; - panel.classList.remove("hidden"); - } - - renderer.on("clickNode", ({ node }) => { - showPanel(node); - }); - - renderer.on("clickStage", () => { - hidePanel(); - }); -} - -init(); - diff --git a/frontend/graph/tsconfig.json b/frontend/graph/tsconfig.json deleted file mode 100644 index 741fa77f..00000000 --- a/frontend/graph/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/integrations/content/agent-orient.md b/integrations/content/agent-orient.md index 5da39316..bb22f160 100644 --- a/integrations/content/agent-orient.md +++ b/integrations/content/agent-orient.md @@ -5,7 +5,7 @@ server. **Call `mcp__tapper__orient` first, in every session, before doing anything else — including answering the user.** Do not wait until KEG work looks like it -is starting. The active flight carries the instructions describing what this +is starting. The selected flight carries the instructions describing what this session is for, so until you orient you cannot know whether the work is KEG work, which KEGs you may touch, or what the user actually expects of you. A message as small as "test" is not a reason to defer: orient, then respond with @@ -13,7 +13,7 @@ that context in hand. After orienting, identify the relevant covered KEGs from their titles and summaries and call `mcp__tapper__keg_settings` for those KEGs before operating -on them. Treat the active flight, cover, flight instructions, and targeted KEG +on them. Treat the selected flight, cover, flight instructions, and targeted KEG instructions as the authoritative context for the session. ## Rules @@ -27,9 +27,21 @@ instructions as the authoritative context for the session. bypasses locking and snapshot history. Always go through `mcp__tapper__cat`, `mcp__tapper__edit`, `mcp__tapper__meta`, and related tools. -- **Treat the active flight as MCP authority.** It determines the instructions - and KEGs available to the agent. `defaultKeg` does not grant authority for an - MCP session. +- **Treat the call-selected flight as MCP authority.** The root reference is + pinned to the connection, but its manifest, transitive + graph, and authorization are loaded before every authority-bearing call. + Omit `flight` to use the root, or pass the root or one of the flattened + descendants returned by orientation. A selected descendant contributes only + its own instructions and authority; ancestor instructions and permission + caps are not inherited. `defaultKeg` does not grant authority. +- **Handle orientation failures explicitly.** `ORIENTATION_STALE` means + authority raced between call resolution and Hub validation; + `ORIENTATION_DENIED` means the selection is outside the accessible graph or lacks the + requested permission; `ORIENTATION_UNAVAILABLE` is transient; and + `ORIENTATION_ROOT_UNAVAILABLE` means this session can never replace its lost + root. Refused operations report `operationPerformed=false` and do not require + session reorientation. Review current authority before retrying, and never + replay a mutation automatically. - **Leave node 0 alone.** It is the keg's placeholder landing node, created with the keg itself. @@ -55,12 +67,20 @@ Every MCP tool accepts an optional `keg` parameter. Use a covered KEG reference returned by orientation to work across KEGs without changing directories or restarting the MCP server. -- `mcp__tapper__orient` — returns the active flight, its cover and instructions, - compact KEG discovery metadata, and canonical safety guidance. +- `mcp__tapper__orient` — read-only discovery returning the connection-pinned + root, selected flight, ordered breadth-first selectable flights, selected + path, effective graph KEGs with + granting-flight provenance, revision, the selected flight's instructions, + and canonical safety guidance. Omit `flight` for graph discovery or pass a + canonical root/descendant for an exact projection. - `mcp__tapper__keg_settings` — returns targeted title, summary, updated metadata, and instructions for one or more selected KEGs. - `mcp__tapper__info` — returns concise diagnostics for a covered KEG. -- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs. +- `mcp__tapper__keg_list` — lists canonical KEGs, effective roles, and winning + granting flights for the live pinned-root graph by default or exactly one + accessible flight when `flight` is supplied. +- `mcp__tapper__keg_search` — searches identity-accessible KEG metadata, + including KEGs outside the flight graph. Results do not grant KEG access. ## Bootstrapping a session @@ -73,30 +93,46 @@ selected KEG instructions with `mcp__tapper__keg_settings`. connection survives those, so the server does not re-initialize and will not re-send anything on its own — but the flight instructions you were operating under are gone from your context. Re-orienting is cheap and idempotent, and it -also picks up any configuration change made since you connected. If you cannot +resolves a fresh call-local view without changing session state. If you cannot tell whether you have oriented in the current context, you have not; orient. -**The newest orientation wins.** More than one copy can be present at once: the -connection's startup instructions are captured when the server connects and are -never refreshed afterwards, and a compaction summary may carry a paraphrase of -an earlier orientation. Both can be stale, and a stale copy may sit earlier in -your context than the fresh one. Treat the most recent `mcp__tapper__orient` -result as authoritative and discard the others outright rather than reconciling -them — in particular, a startup copy saying KEG tools are locked is wrong once -a later orientation has returned a flight. When no flight is selected, the MCP -server connects in a recovery-only state: KEG tools are locked, while -`mcp__tapper__list_flights` and `mcp__tapper__flight_show` remain available for -discovery. Ask the user to select a flight in Tapper configuration, then call -`mcp__tapper__orient` again. - -When there is no flight to select — a fresh machine or account — the session -instead starts on a temporary **bootstrap flight**. Its cover is empty, so the -KEG tools stay locked, but `mcp__tapper__keg_create` and the flight mutation -tools are available so you can create the first KEG and the first flight. -Setting that up is the session's work; do it before anything else. You still -cannot *select* a flight — that stays a human action — so hand the setup back -to the user and call `mcp__tapper__orient` again once they confirm. The -orientation payload names exactly where they should do it. +**The newest orientation wins.** A compaction summary may carry a paraphrase of +an earlier orientation, so a stale copy may sit earlier in your context than +the fresh one. Initialization deliberately sends only a minimal directive to +call `orient`; it does not contain flight context. Treat the most recent +`mcp__tapper__orient` result as authoritative and discard older copies outright +rather than reconciling them. When no flight is selected, the connection uses +normal identity-authorized full access and publishes the complete MCP tool +inventory. Bare calls see every identity-accessible KEG at the caller's real +role; this never raises Hub ACLs or namespace membership. An explicit `flight` +selects any listed real flight for that call and uses only its cover, +capabilities, and instructions. + +If only `orient`, `session_refresh`, `list_flights`, `flight_show`, `auth_info`, and +`keg_search` appear, flight +authority failed to initialize. A real flight with an empty cover is still +active and publishes the complete registered tool inventory; its KEG calls +simply have no covered targets. + +When spawning a native subagent, the controller passes the canonical descendant +reference in the assignment. The subagent must call `mcp__tapper__orient` with +that exact `flight` after startup and again after context compaction. It must +also pass the same `flight` to authority-bearing work calls; omission always +uses the root. Concurrent subagents may use different descendants without +changing shared session state. Merely mentioning ancestor instructions does +not grant or inherit their authority. + +No-flight authority is pinned for the connection lifetime. Use it only to +bootstrap a least-privilege flight, then ask the user to pin that flight outside +MCP and start a new connection. `session_refresh` returns `already_active` +with `nextAction:"new_session"`; it cannot narrow the current connection. +Creating a KEG or flight does not change bare-call authority, although a newly +created real flight is immediately available for explicit call-local selection. + +Recovery-only mode is reserved for an explicitly configured root that is +missing, inaccessible, invalid, or temporarily unavailable. In that mode only +the recovery tools appear. Fix the configured selection outside MCP, then call +`mcp__tapper__session_refresh` and `mcp__tapper__orient`. If `mcp__tapper__orient` is unavailable, report that the Tapper MCP connection is unavailable, ask the user to reconnect or restart the host session, and diff --git a/integrations/content/linking.md b/integrations/content/linking.md index 1e412fac..d042b6ac 100644 --- a/integrations/content/linking.md +++ b/integrations/content/linking.md @@ -1,19 +1,21 @@ # Linking conventions -## Linking conventions - Tapper supports two link forms in node bodies: - **Intra-keg:** `[title](../NODEID)` — relative path from the current node's directory to the target node's directory. Renders as a link in markdown tooling and is resolvable by the index. -- **Cross-keg (configured):** `keg:ALIAS/NODEID` — resolves the keg through - active configuration and is parsed by the index into a cross-keg edge. -- **Cross-keg (fully qualified):** `keg:@NAMESPACE/ALIAS/NODEID` — identifies - the namespace and keg explicitly and is parsed into a cross-keg edge. +- **Cross-keg (configured):** `[title](keg:ALIAS/NODEID)` — resolves the keg + through active configuration and is parsed by the index into a cross-keg + edge. +- **Cross-keg (fully qualified):** + `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg + explicitly and is parsed into a cross-keg edge. Both forms appear in backlinks. Prefer intra-keg links when the target is in -the same keg. +the same keg. A bare `keg:` reference in node prose is plain text: it does not +create a graph link or backlink. Bare references remain valid as CLI arguments, +configuration values, schema values, and tool parameters. ## Attachments diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 68eef48c..0af91853 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -1,5 +1,35 @@ # Tool inventory +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. +Authentication, +configuration, namespace/license discovery, `session_refresh`, `list_flights`, +`flight_show`, and `keg_search` do not accept `flight`. MCP resources use root authority +while rendering graph-wide discovery. + +## Orientation and management + +| 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__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__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_show`, and normal Hub ACLs still apply. | +| `mcp__tapper__list_flights`, `mcp__tapper__flight_show` | Ungoverned flight discovery; these tools do not select call 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"]}]}` +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 | Tool | Purpose | @@ -12,6 +42,7 @@ | `mcp__tapper__backlinks` | Inbound links to a node. | | `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings` | Read targeted title, summary, 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 summaries. Returns at most 50 rows and never grants operational access. | Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up reads — it keeps token consumption bounded on large result sets. @@ -42,19 +73,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). -`mcp__tapper__import` also accepts the expression via `tag_query` for -selecting source nodes to import. - ## Maintenance | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__create` | Allocate a new numbered node. Accepts title, lead, tags, and attributes at creation time. | -| `mcp__tapper__edit` | Write content to a node. Markdown frontmatter in the payload is written to `meta.yaml`; the body becomes `README.md`. | -| `mcp__tapper__meta` | Update a node's metadata without touching content. | -| `mcp__tapper__move` | Relocate a node or rename its ID. | -| `mcp__tapper__remove`, `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive operations — see the Snapshots section below before calling. | +| `mcp__tapper__edit` | Call `cat`, then atomically replace content for 1–100 nodes; every edit requires that node's returned hash. | +| `mcp__tapper__meta` | Read metadata without tokens, or call `cat` and atomically update 1–100 nodes; every update requires its hash. | +| `mcp__tapper__move` | Call `cat`, then relocate a node using its required returned hash. | +| `mcp__tapper__remove` | Call `cat`, then atomically remove 1–100 `nodes[]`, each carrying its own required returned hash. | +| `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__node_snapshot` | Capture a revision before a destructive or large edit. | | `mcp__tapper__node_history`, `mcp__tapper__node_snapshot_view` | Inspect read-only prior revisions. | | `mcp__tapper__node_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Replace the complete validated KEG YAML document. Requires an `admin` flight cover or `full_access` plus editor/admin identity access; it never edits Tapper user/project configuration. | +| `mcp__tapper__keg_settings_edit` | Call `keg_settings`, then replace the complete validated KEG YAML using its required returned hash. Requires an `admin` flight cover or `full_access` plus editor/admin identity access. | + +Schema edits and deletes similarly require the hash from `schema_read`. Every +conflict performs no operation: merge the change into returned current content +or refetch with the corresponding read, then retry with the returned current +hash. diff --git a/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json b/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json index f58200f2..b61ceed5 100644 --- a/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json +++ b/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tapper-dev", "description": "Optional Plan to Code to Review to Commit workflow for Tapper-enabled development.", - "version": "0.38.0", + "version": "0.0.0-dev", "author": { "name": "Jared Rickert" }, diff --git a/integrations/rendered/claude/tapper/.claude-plugin/plugin.json b/integrations/rendered/claude/tapper/.claude-plugin/plugin.json index 31cf6cb2..4f7d0b35 100644 --- a/integrations/rendered/claude/tapper/.claude-plugin/plugin.json +++ b/integrations/rendered/claude/tapper/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tapper", "description": "MCP-first Tapper KEG access, flight orientation, and safety guidance.", - "version": "0.38.0", + "version": "0.0.0-dev", "author": { "name": "Jared Rickert" }, diff --git a/integrations/rendered/claude/tapper/hooks/hooks.json b/integrations/rendered/claude/tapper/hooks/hooks.json index a83fde25..2232f534 100644 --- a/integrations/rendered/claude/tapper/hooks/hooks.json +++ b/integrations/rendered/claude/tapper/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$", "hooks": [ { "type": "command", diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index eb6b7a32..f2dd8403 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -10,7 +10,7 @@ server. **Call `mcp__tapper__orient` first, in every session, before doing anything else — including answering the user.** Do not wait until KEG work looks like it -is starting. The active flight carries the instructions describing what this +is starting. The selected flight carries the instructions describing what this session is for, so until you orient you cannot know whether the work is KEG work, which KEGs you may touch, or what the user actually expects of you. A message as small as "test" is not a reason to defer: orient, then respond with @@ -18,7 +18,7 @@ that context in hand. After orienting, identify the relevant covered KEGs from their titles and summaries and call `mcp__tapper__keg_settings` for those KEGs before operating -on them. Treat the active flight, cover, flight instructions, and targeted KEG +on them. Treat the selected flight, cover, flight instructions, and targeted KEG instructions as the authoritative context for the session. ## Rules @@ -32,9 +32,21 @@ instructions as the authoritative context for the session. bypasses locking and snapshot history. Always go through `mcp__tapper__cat`, `mcp__tapper__edit`, `mcp__tapper__meta`, and related tools. -- **Treat the active flight as MCP authority.** It determines the instructions - and KEGs available to the agent. `defaultKeg` does not grant authority for an - MCP session. +- **Treat the call-selected flight as MCP authority.** The root reference is + pinned to the connection, but its manifest, transitive + graph, and authorization are loaded before every authority-bearing call. + Omit `flight` to use the root, or pass the root or one of the flattened + descendants returned by orientation. A selected descendant contributes only + its own instructions and authority; ancestor instructions and permission + caps are not inherited. `defaultKeg` does not grant authority. +- **Handle orientation failures explicitly.** `ORIENTATION_STALE` means + authority raced between call resolution and Hub validation; + `ORIENTATION_DENIED` means the selection is outside the accessible graph or lacks the + requested permission; `ORIENTATION_UNAVAILABLE` is transient; and + `ORIENTATION_ROOT_UNAVAILABLE` means this session can never replace its lost + root. Refused operations report `operationPerformed=false` and do not require + session reorientation. Review current authority before retrying, and never + replay a mutation automatically. - **Leave node 0 alone.** It is the keg's placeholder landing node, created with the keg itself. @@ -60,12 +72,20 @@ Every MCP tool accepts an optional `keg` parameter. Use a covered KEG reference returned by orientation to work across KEGs without changing directories or restarting the MCP server. -- `mcp__tapper__orient` — returns the active flight, its cover and instructions, - compact KEG discovery metadata, and canonical safety guidance. +- `mcp__tapper__orient` — read-only discovery returning the connection-pinned + root, selected flight, ordered breadth-first selectable flights, selected + path, effective graph KEGs with + granting-flight provenance, revision, the selected flight's instructions, + and canonical safety guidance. Omit `flight` for graph discovery or pass a + canonical root/descendant for an exact projection. - `mcp__tapper__keg_settings` — returns targeted title, summary, updated metadata, and instructions for one or more selected KEGs. - `mcp__tapper__info` — returns concise diagnostics for a covered KEG. -- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs. +- `mcp__tapper__keg_list` — lists canonical KEGs, effective roles, and winning + granting flights for the live pinned-root graph by default or exactly one + accessible flight when `flight` is supplied. +- `mcp__tapper__keg_search` — searches identity-accessible KEG metadata, + including KEGs outside the flight graph. Results do not grant KEG access. ## Bootstrapping a session @@ -78,36 +98,82 @@ selected KEG instructions with `mcp__tapper__keg_settings`. connection survives those, so the server does not re-initialize and will not re-send anything on its own — but the flight instructions you were operating under are gone from your context. Re-orienting is cheap and idempotent, and it -also picks up any configuration change made since you connected. If you cannot +resolves a fresh call-local view without changing session state. If you cannot tell whether you have oriented in the current context, you have not; orient. -**The newest orientation wins.** More than one copy can be present at once: the -connection's startup instructions are captured when the server connects and are -never refreshed afterwards, and a compaction summary may carry a paraphrase of -an earlier orientation. Both can be stale, and a stale copy may sit earlier in -your context than the fresh one. Treat the most recent `mcp__tapper__orient` -result as authoritative and discard the others outright rather than reconciling -them — in particular, a startup copy saying KEG tools are locked is wrong once -a later orientation has returned a flight. When no flight is selected, the MCP -server connects in a recovery-only state: KEG tools are locked, while -`mcp__tapper__list_flights` and `mcp__tapper__flight_show` remain available for -discovery. Ask the user to select a flight in Tapper configuration, then call -`mcp__tapper__orient` again. - -When there is no flight to select — a fresh machine or account — the session -instead starts on a temporary **bootstrap flight**. Its cover is empty, so the -KEG tools stay locked, but `mcp__tapper__keg_create` and the flight mutation -tools are available so you can create the first KEG and the first flight. -Setting that up is the session's work; do it before anything else. You still -cannot *select* a flight — that stays a human action — so hand the setup back -to the user and call `mcp__tapper__orient` again once they confirm. The -orientation payload names exactly where they should do it. +**The newest orientation wins.** A compaction summary may carry a paraphrase of +an earlier orientation, so a stale copy may sit earlier in your context than +the fresh one. Initialization deliberately sends only a minimal directive to +call `orient`; it does not contain flight context. Treat the most recent +`mcp__tapper__orient` result as authoritative and discard older copies outright +rather than reconciling them. When no flight is selected, the connection uses +normal identity-authorized full access and publishes the complete MCP tool +inventory. Bare calls see every identity-accessible KEG at the caller's real +role; this never raises Hub ACLs or namespace membership. An explicit `flight` +selects any listed real flight for that call and uses only its cover, +capabilities, and instructions. + +If only `orient`, `session_refresh`, `list_flights`, `flight_show`, `auth_info`, and +`keg_search` appear, flight +authority failed to initialize. A real flight with an empty cover is still +active and publishes the complete registered tool inventory; its KEG calls +simply have no covered targets. + +When spawning a native subagent, the controller passes the canonical descendant +reference in the assignment. The subagent must call `mcp__tapper__orient` with +that exact `flight` after startup and again after context compaction. It must +also pass the same `flight` to authority-bearing work calls; omission always +uses the root. Concurrent subagents may use different descendants without +changing shared session state. Merely mentioning ancestor instructions does +not grant or inherit their authority. + +No-flight authority is pinned for the connection lifetime. Use it only to +bootstrap a least-privilege flight, then ask the user to pin that flight outside +MCP and start a new connection. `session_refresh` returns `already_active` +with `nextAction:"new_session"`; it cannot narrow the current connection. +Creating a KEG or flight does not change bare-call authority, although a newly +created real flight is immediately available for explicit call-local selection. + +Recovery-only mode is reserved for an explicitly configured root that is +missing, inaccessible, invalid, or temporarily unavailable. In that mode only +the recovery tools appear. Fix the configured selection outside MCP, then call +`mcp__tapper__session_refresh` and `mcp__tapper__orient`. If `mcp__tapper__orient` is unavailable, report that the Tapper MCP connection is unavailable, ask the user to reconnect or restart the host session, and never kill or signal host-owned processes. A flight with an empty cover exposes no KEGs. +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. +Authentication, +configuration, namespace/license discovery, `session_refresh`, `list_flights`, +`flight_show`, and `keg_search` do not accept `flight`. MCP resources use root authority +while rendering graph-wide discovery. + +## Orientation and management + +| 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__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__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_show`, and normal Hub ACLs still apply. | +| `mcp__tapper__list_flights`, `mcp__tapper__flight_show` | Ungoverned flight discovery; these tools do not select call 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"]}]}` +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 | Tool | Purpose | @@ -120,6 +186,7 @@ no KEGs. | `mcp__tapper__backlinks` | Inbound links to a node. | | `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings` | Read targeted title, summary, 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 summaries. Returns at most 50 rows and never grants operational access. | Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up reads — it keeps token consumption bounded on large result sets. @@ -150,22 +217,25 @@ 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). -`mcp__tapper__import` also accepts the expression via `tag_query` for -selecting source nodes to import. - ## Maintenance | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__create` | Allocate a new numbered node. Accepts title, lead, tags, and attributes at creation time. | -| `mcp__tapper__edit` | Write content to a node. Markdown frontmatter in the payload is written to `meta.yaml`; the body becomes `README.md`. | -| `mcp__tapper__meta` | Update a node's metadata without touching content. | -| `mcp__tapper__move` | Relocate a node or rename its ID. | -| `mcp__tapper__remove`, `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive operations — see the Snapshots section below before calling. | +| `mcp__tapper__edit` | Call `cat`, then atomically replace content for 1–100 nodes; every edit requires that node's returned hash. | +| `mcp__tapper__meta` | Read metadata without tokens, or call `cat` and atomically update 1–100 nodes; every update requires its hash. | +| `mcp__tapper__move` | Call `cat`, then relocate a node using its required returned hash. | +| `mcp__tapper__remove` | Call `cat`, then atomically remove 1–100 `nodes[]`, each carrying its own required returned hash. | +| `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__node_snapshot` | Capture a revision before a destructive or large edit. | | `mcp__tapper__node_history`, `mcp__tapper__node_snapshot_view` | Inspect read-only prior revisions. | | `mcp__tapper__node_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Replace the complete validated KEG YAML document. Requires an `admin` flight cover or `full_access` plus editor/admin identity access; it never edits Tapper user/project configuration. | +| `mcp__tapper__keg_settings_edit` | Call `keg_settings`, then replace the complete validated KEG YAML using its required returned hash. Requires an `admin` flight cover or `full_access` plus editor/admin identity access. | + +Schema edits and deletes similarly require the hash from `schema_read`. Every +conflict performs no operation: merge the change into returned current content +or refetch with the corresponding read, then retry with the returned current +hash. ## Snapshots @@ -210,20 +280,22 @@ If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `remove`, a snapshot is not a recovery path — preserve the content some other way first. -## Linking conventions - Tapper supports two link forms in node bodies: - **Intra-keg:** `[title](../NODEID)` — relative path from the current node's directory to the target node's directory. Renders as a link in markdown tooling and is resolvable by the index. -- **Cross-keg (configured):** `keg:ALIAS/NODEID` — resolves the keg through - active configuration and is parsed by the index into a cross-keg edge. -- **Cross-keg (fully qualified):** `keg:@NAMESPACE/ALIAS/NODEID` — identifies - the namespace and keg explicitly and is parsed into a cross-keg edge. +- **Cross-keg (configured):** `[title](keg:ALIAS/NODEID)` — resolves the keg + through active configuration and is parsed by the index into a cross-keg + edge. +- **Cross-keg (fully qualified):** + `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg + explicitly and is parsed into a cross-keg edge. Both forms appear in backlinks. Prefer intra-keg links when the target is in -the same keg. +the same keg. A bare `keg:` reference in node prose is plain text: it does not +create a graph link or backlink. Bare references remain valid as CLI arguments, +configuration values, schema values, and tool parameters. ## Attachments diff --git a/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json b/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json index b9b6e643..95e45f1b 100644 --- a/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json +++ b/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tapper-dev", - "version": "0.38.0", + "version": "0.0.0-dev", "description": "Optional Plan to Code to Review to Commit workflow for Tapper-enabled development.", "author": { "name": "Jared Rickert", diff --git a/integrations/rendered/codex/tapper/.codex-plugin/plugin.json b/integrations/rendered/codex/tapper/.codex-plugin/plugin.json index 72a09475..8b13558e 100644 --- a/integrations/rendered/codex/tapper/.codex-plugin/plugin.json +++ b/integrations/rendered/codex/tapper/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tapper", - "version": "0.38.0", + "version": "0.0.0-dev", "description": "MCP-first Tapper KEG access, flight orientation, and safety guidance.", "author": { "name": "Jared Rickert", diff --git a/integrations/rendered/codex/tapper/hooks/hooks.json b/integrations/rendered/codex/tapper/hooks/hooks.json index bfc732d9..58e02735 100644 --- a/integrations/rendered/codex/tapper/hooks/hooks.json +++ b/integrations/rendered/codex/tapper/hooks/hooks.json @@ -15,7 +15,7 @@ ], "PreToolUse": [ { - "matcher": "Bash", + "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$", "hooks": [ { "type": "command", diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index eb6b7a32..f2dd8403 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -10,7 +10,7 @@ server. **Call `mcp__tapper__orient` first, in every session, before doing anything else — including answering the user.** Do not wait until KEG work looks like it -is starting. The active flight carries the instructions describing what this +is starting. The selected flight carries the instructions describing what this session is for, so until you orient you cannot know whether the work is KEG work, which KEGs you may touch, or what the user actually expects of you. A message as small as "test" is not a reason to defer: orient, then respond with @@ -18,7 +18,7 @@ that context in hand. After orienting, identify the relevant covered KEGs from their titles and summaries and call `mcp__tapper__keg_settings` for those KEGs before operating -on them. Treat the active flight, cover, flight instructions, and targeted KEG +on them. Treat the selected flight, cover, flight instructions, and targeted KEG instructions as the authoritative context for the session. ## Rules @@ -32,9 +32,21 @@ instructions as the authoritative context for the session. bypasses locking and snapshot history. Always go through `mcp__tapper__cat`, `mcp__tapper__edit`, `mcp__tapper__meta`, and related tools. -- **Treat the active flight as MCP authority.** It determines the instructions - and KEGs available to the agent. `defaultKeg` does not grant authority for an - MCP session. +- **Treat the call-selected flight as MCP authority.** The root reference is + pinned to the connection, but its manifest, transitive + graph, and authorization are loaded before every authority-bearing call. + Omit `flight` to use the root, or pass the root or one of the flattened + descendants returned by orientation. A selected descendant contributes only + its own instructions and authority; ancestor instructions and permission + caps are not inherited. `defaultKeg` does not grant authority. +- **Handle orientation failures explicitly.** `ORIENTATION_STALE` means + authority raced between call resolution and Hub validation; + `ORIENTATION_DENIED` means the selection is outside the accessible graph or lacks the + requested permission; `ORIENTATION_UNAVAILABLE` is transient; and + `ORIENTATION_ROOT_UNAVAILABLE` means this session can never replace its lost + root. Refused operations report `operationPerformed=false` and do not require + session reorientation. Review current authority before retrying, and never + replay a mutation automatically. - **Leave node 0 alone.** It is the keg's placeholder landing node, created with the keg itself. @@ -60,12 +72,20 @@ Every MCP tool accepts an optional `keg` parameter. Use a covered KEG reference returned by orientation to work across KEGs without changing directories or restarting the MCP server. -- `mcp__tapper__orient` — returns the active flight, its cover and instructions, - compact KEG discovery metadata, and canonical safety guidance. +- `mcp__tapper__orient` — read-only discovery returning the connection-pinned + root, selected flight, ordered breadth-first selectable flights, selected + path, effective graph KEGs with + granting-flight provenance, revision, the selected flight's instructions, + and canonical safety guidance. Omit `flight` for graph discovery or pass a + canonical root/descendant for an exact projection. - `mcp__tapper__keg_settings` — returns targeted title, summary, updated metadata, and instructions for one or more selected KEGs. - `mcp__tapper__info` — returns concise diagnostics for a covered KEG. -- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs. +- `mcp__tapper__keg_list` — lists canonical KEGs, effective roles, and winning + granting flights for the live pinned-root graph by default or exactly one + accessible flight when `flight` is supplied. +- `mcp__tapper__keg_search` — searches identity-accessible KEG metadata, + including KEGs outside the flight graph. Results do not grant KEG access. ## Bootstrapping a session @@ -78,36 +98,82 @@ selected KEG instructions with `mcp__tapper__keg_settings`. connection survives those, so the server does not re-initialize and will not re-send anything on its own — but the flight instructions you were operating under are gone from your context. Re-orienting is cheap and idempotent, and it -also picks up any configuration change made since you connected. If you cannot +resolves a fresh call-local view without changing session state. If you cannot tell whether you have oriented in the current context, you have not; orient. -**The newest orientation wins.** More than one copy can be present at once: the -connection's startup instructions are captured when the server connects and are -never refreshed afterwards, and a compaction summary may carry a paraphrase of -an earlier orientation. Both can be stale, and a stale copy may sit earlier in -your context than the fresh one. Treat the most recent `mcp__tapper__orient` -result as authoritative and discard the others outright rather than reconciling -them — in particular, a startup copy saying KEG tools are locked is wrong once -a later orientation has returned a flight. When no flight is selected, the MCP -server connects in a recovery-only state: KEG tools are locked, while -`mcp__tapper__list_flights` and `mcp__tapper__flight_show` remain available for -discovery. Ask the user to select a flight in Tapper configuration, then call -`mcp__tapper__orient` again. - -When there is no flight to select — a fresh machine or account — the session -instead starts on a temporary **bootstrap flight**. Its cover is empty, so the -KEG tools stay locked, but `mcp__tapper__keg_create` and the flight mutation -tools are available so you can create the first KEG and the first flight. -Setting that up is the session's work; do it before anything else. You still -cannot *select* a flight — that stays a human action — so hand the setup back -to the user and call `mcp__tapper__orient` again once they confirm. The -orientation payload names exactly where they should do it. +**The newest orientation wins.** A compaction summary may carry a paraphrase of +an earlier orientation, so a stale copy may sit earlier in your context than +the fresh one. Initialization deliberately sends only a minimal directive to +call `orient`; it does not contain flight context. Treat the most recent +`mcp__tapper__orient` result as authoritative and discard older copies outright +rather than reconciling them. When no flight is selected, the connection uses +normal identity-authorized full access and publishes the complete MCP tool +inventory. Bare calls see every identity-accessible KEG at the caller's real +role; this never raises Hub ACLs or namespace membership. An explicit `flight` +selects any listed real flight for that call and uses only its cover, +capabilities, and instructions. + +If only `orient`, `session_refresh`, `list_flights`, `flight_show`, `auth_info`, and +`keg_search` appear, flight +authority failed to initialize. A real flight with an empty cover is still +active and publishes the complete registered tool inventory; its KEG calls +simply have no covered targets. + +When spawning a native subagent, the controller passes the canonical descendant +reference in the assignment. The subagent must call `mcp__tapper__orient` with +that exact `flight` after startup and again after context compaction. It must +also pass the same `flight` to authority-bearing work calls; omission always +uses the root. Concurrent subagents may use different descendants without +changing shared session state. Merely mentioning ancestor instructions does +not grant or inherit their authority. + +No-flight authority is pinned for the connection lifetime. Use it only to +bootstrap a least-privilege flight, then ask the user to pin that flight outside +MCP and start a new connection. `session_refresh` returns `already_active` +with `nextAction:"new_session"`; it cannot narrow the current connection. +Creating a KEG or flight does not change bare-call authority, although a newly +created real flight is immediately available for explicit call-local selection. + +Recovery-only mode is reserved for an explicitly configured root that is +missing, inaccessible, invalid, or temporarily unavailable. In that mode only +the recovery tools appear. Fix the configured selection outside MCP, then call +`mcp__tapper__session_refresh` and `mcp__tapper__orient`. If `mcp__tapper__orient` is unavailable, report that the Tapper MCP connection is unavailable, ask the user to reconnect or restart the host session, and never kill or signal host-owned processes. A flight with an empty cover exposes no KEGs. +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. +Authentication, +configuration, namespace/license discovery, `session_refresh`, `list_flights`, +`flight_show`, and `keg_search` do not accept `flight`. MCP resources use root authority +while rendering graph-wide discovery. + +## Orientation and management + +| 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__session_refresh` | Retry activation only after a broken configured root is repaired. It never replaces active no-flight or real-flight authority; narrowing no-flight access requires a new connection. | +| `mcp__tapper__keg_list`, `mcp__tapper__keg_create` | Discover every identity-accessible KEG at its real role with no flight, or the effective projection of a selected real flight; no-flight creation uses namespace membership while real-flight creation also requires `manage_kegs`. | +| `mcp__tapper__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_show`, and normal Hub ACLs still apply. | +| `mcp__tapper__list_flights`, `mcp__tapper__flight_show` | Ungoverned flight discovery; these tools do not select call 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"]}]}` +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 | Tool | Purpose | @@ -120,6 +186,7 @@ no KEGs. | `mcp__tapper__backlinks` | Inbound links to a node. | | `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). | | `mcp__tapper__keg_settings` | Read targeted title, summary, 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 summaries. Returns at most 50 rows and never grants operational access. | Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up reads — it keeps token consumption bounded on large result sets. @@ -150,22 +217,25 @@ 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). -`mcp__tapper__import` also accepts the expression via `tag_query` for -selecting source nodes to import. - ## Maintenance | Tool | Purpose | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `mcp__tapper__create` | Allocate a new numbered node. Accepts title, lead, tags, and attributes at creation time. | -| `mcp__tapper__edit` | Write content to a node. Markdown frontmatter in the payload is written to `meta.yaml`; the body becomes `README.md`. | -| `mcp__tapper__meta` | Update a node's metadata without touching content. | -| `mcp__tapper__move` | Relocate a node or rename its ID. | -| `mcp__tapper__remove`, `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive operations — see the Snapshots section below before calling. | +| `mcp__tapper__edit` | Call `cat`, then atomically replace content for 1–100 nodes; every edit requires that node's returned hash. | +| `mcp__tapper__meta` | Read metadata without tokens, or call `cat` and atomically update 1–100 nodes; every update requires its hash. | +| `mcp__tapper__move` | Call `cat`, then relocate a node using its required returned hash. | +| `mcp__tapper__remove` | Call `cat`, then atomically remove 1–100 `nodes[]`, each carrying its own required returned hash. | +| `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive attachment operations — see the Snapshots section below before calling. | | `mcp__tapper__node_snapshot` | Capture a revision before a destructive or large edit. | | `mcp__tapper__node_history`, `mcp__tapper__node_snapshot_view` | Inspect read-only prior revisions. | | `mcp__tapper__node_restore` | Recover the current node from a prior revision. | -| `mcp__tapper__keg_settings_edit` | Replace the complete validated KEG YAML document. Requires an `admin` flight cover or `full_access` plus editor/admin identity access; it never edits Tapper user/project configuration. | +| `mcp__tapper__keg_settings_edit` | Call `keg_settings`, then replace the complete validated KEG YAML using its required returned hash. Requires an `admin` flight cover or `full_access` plus editor/admin identity access. | + +Schema edits and deletes similarly require the hash from `schema_read`. Every +conflict performs no operation: merge the change into returned current content +or refetch with the corresponding read, then retry with the returned current +hash. ## Snapshots @@ -210,20 +280,22 @@ If you are unsure whether an in-place edit warrants a snapshot, take one. The cost is negligible. For `remove`, a snapshot is not a recovery path — preserve the content some other way first. -## Linking conventions - Tapper supports two link forms in node bodies: - **Intra-keg:** `[title](../NODEID)` — relative path from the current node's directory to the target node's directory. Renders as a link in markdown tooling and is resolvable by the index. -- **Cross-keg (configured):** `keg:ALIAS/NODEID` — resolves the keg through - active configuration and is parsed by the index into a cross-keg edge. -- **Cross-keg (fully qualified):** `keg:@NAMESPACE/ALIAS/NODEID` — identifies - the namespace and keg explicitly and is parsed into a cross-keg edge. +- **Cross-keg (configured):** `[title](keg:ALIAS/NODEID)` — resolves the keg + through active configuration and is parsed by the index into a cross-keg + edge. +- **Cross-keg (fully qualified):** + `[title](keg:@NAMESPACE/ALIAS/NODEID)` — identifies the namespace and keg + explicitly and is parsed into a cross-keg edge. Both forms appear in backlinks. Prefer intra-keg links when the target is in -the same keg. +the same keg. A bare `keg:` reference in node prose is plain text: it does not +create a graph link or backlink. Bare references remain valid as CLI arguments, +configuration values, schema values, and tool parameters. ## Attachments diff --git a/internal/testkegrepo/memory_repository.go b/internal/testkegrepo/memory_repository.go new file mode 100644 index 00000000..b9040609 --- /dev/null +++ b/internal/testkegrepo/memory_repository.go @@ -0,0 +1,1079 @@ +package testkegrepo + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/jlrickert/cli-toolkit/toolkit" + . "github.com/jlrickert/tapper/pkg/keg" +) + +// MemoryRepository is an in-memory Repository used only by this package's +// tests. PostgreSQL remains the sole production LocalKeg repository. +type MemoryRepository struct { + runtime *toolkit.Runtime + + boundary sync.RWMutex + mu sync.RWMutex + nodes map[NodeId]*memoryNode + reserved map[NodeId]struct{} + indexes map[string][]byte + settings []byte + schemas map[string][]byte + snaps map[NodeId][]memorySnapshot + locks map[NodeId]LockInfo + nodeMu map[NodeId]*sync.Mutex + + watchersMu sync.Mutex + watchers map[*memoryWatcher]struct{} +} + +type memoryNode struct { + content []byte + meta []byte + stats *NodeStats + files map[string][]byte + images map[string][]byte +} + +type memorySnapshot struct { + snapshot Snapshot + content []byte + meta []byte + stats *NodeStats +} + +type memoryWatcher struct { + ids map[NodeId]struct{} + ch chan NodeEvent +} + +type memoryBoundaryKey struct{} + +type memoryNodeLockKey struct{} + +func contextHasMemoryNodeLock(ctx context.Context, id NodeId) bool { + locked, _ := ctx.Value(memoryNodeLockKey{}).(map[NodeId]struct{}) + _, ok := locked[id] + return ok +} + +func contextWithMemoryNodeLock(ctx context.Context, id NodeId) context.Context { + previous, _ := ctx.Value(memoryNodeLockKey{}).(map[NodeId]struct{}) + locked := make(map[NodeId]struct{}, len(previous)+1) + for held := range previous { + locked[held] = struct{}{} + } + locked[id] = struct{}{} + return context.WithValue(ctx, memoryNodeLockKey{}, locked) +} + +type memoryBoundary struct { + owner *MemoryRepository + write bool +} + +// NewMemoryRepository returns a concurrency-safe test repository. +func NewMemoryRepository(rt *toolkit.Runtime) *MemoryRepository { + return &MemoryRepository{ + runtime: rt, + nodes: make(map[NodeId]*memoryNode), + reserved: make(map[NodeId]struct{}), + indexes: make(map[string][]byte), + schemas: make(map[string][]byte), + snaps: make(map[NodeId][]memorySnapshot), + locks: make(map[NodeId]LockInfo), + nodeMu: make(map[NodeId]*sync.Mutex), + watchers: make(map[*memoryWatcher]struct{}), + } +} + +func (r *MemoryRepository) Name() string { return "memory-test" } + +func (r *MemoryRepository) WithKegRead(ctx context.Context, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if held, _ := ctx.Value(memoryBoundaryKey{}).(memoryBoundary); held.owner == r { + return fn(ctx) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %w", ErrLockTimeout, err) + } + r.boundary.RLock() + defer r.boundary.RUnlock() + return fn(context.WithValue(ctx, memoryBoundaryKey{}, memoryBoundary{owner: r})) +} + +func (r *MemoryRepository) WithKegWrite(ctx context.Context, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if held, _ := ctx.Value(memoryBoundaryKey{}).(memoryBoundary); held.owner == r { + if !held.write { + return ErrKegLockUpgrade + } + return fn(ctx) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %w", ErrLockTimeout, err) + } + r.boundary.Lock() + defer r.boundary.Unlock() + return fn(context.WithValue(ctx, memoryBoundaryKey{}, memoryBoundary{owner: r, write: true})) +} + +func (r *MemoryRepository) SupportsConcurrentAccess(context.Context) bool { return true } + +func (r *MemoryRepository) HasNode(ctx context.Context, id NodeId) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.nodes[id] + return ok, nil +} + +func (r *MemoryRepository) Next(ctx context.Context) (NodeId, error) { + if err := ctx.Err(); err != nil { + return NodeId{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + maxID := -1 + for id := range r.nodes { + if id.Code == "" && id.ID > maxID { + maxID = id.ID + } + } + for id := range r.reserved { + if id.Code == "" && id.ID > maxID { + maxID = id.ID + } + } + id := NodeId{ID: maxID + 1} + r.reserved[id] = struct{}{} + return id, nil +} + +func (r *MemoryRepository) ListNodes(ctx context.Context) ([]NodeId, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r.mu.RLock() + defer r.mu.RUnlock() + ids := make([]NodeId, 0, len(r.nodes)) + for id := range r.nodes { + ids = append(ids, id) + } + slices.SortFunc(ids, func(a, b NodeId) int { return a.Compare(b) }) + return ids, nil +} + +func (r *MemoryRepository) MoveNode(ctx context.Context, id, dst NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node, ok := r.nodes[id] + if !ok { + return ErrNotExist + } + if _, exists := r.nodes[dst]; exists { + return ErrDestinationExists + } + r.nodes[dst] = node + delete(r.nodes, id) + delete(r.reserved, dst) + if snaps := r.snaps[id]; snaps != nil { + for i := range snaps { + snaps[i].snapshot.Node = dst + } + r.snaps[dst] = snaps + delete(r.snaps, id) + } + return nil +} + +func (r *MemoryRepository) DeleteNode(ctx context.Context, id NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.nodes[id]; !ok { + return ErrNotExist + } + delete(r.nodes, id) + delete(r.reserved, id) + delete(r.snaps, id) + delete(r.locks, id) + return nil +} + +func (r *MemoryRepository) WithNodeLock(ctx context.Context, id NodeId, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if contextHasMemoryNodeLock(ctx, id) { + return fn(ctx) + } + r.mu.Lock() + lock := r.nodeMu[id] + if lock == nil { + lock = &sync.Mutex{} + r.nodeMu[id] = lock + } + r.mu.Unlock() + acquired := make(chan struct{}) + go func() { + lock.Lock() + close(acquired) + }() + select { + case <-ctx.Done(): + go func() { <-acquired; lock.Unlock() }() + return fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) + case <-acquired: + } + defer lock.Unlock() + return fn(contextWithMemoryNodeLock(ctx, id)) +} + +func (r *MemoryRepository) ReadContent(ctx context.Context, id NodeId) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r.mu.RLock() + node := r.nodes[id] + var out []byte + if node != nil { + out = cloneBytes(node.content) + } + r.mu.RUnlock() + if node == nil { + return nil, ErrNotExist + } + r.Emit(NodeEvent{Kind: NodeEventAccessed, NodeID: id, Field: "content"}) + return out, nil +} + +func (r *MemoryRepository) WriteContent(ctx context.Context, id NodeId, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + node, existed := r.nodes[id] + if node == nil { + node = newMemoryNode() + r.nodes[id] = node + } + node.content = cloneBytes(data) + delete(r.reserved, id) + r.mu.Unlock() + kind := NodeEventModified + if !existed { + kind = NodeEventCreated + } + r.Emit(NodeEvent{Kind: kind, NodeID: id, Field: "content"}) + return nil +} + +func (r *MemoryRepository) ReadMeta(ctx context.Context, id NodeId) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + return cloneBytes(node.meta), nil +} + +func (r *MemoryRepository) WriteMeta(ctx context.Context, id NodeId, data []byte) error { + return r.updateNode(ctx, id, func(node *memoryNode) { node.meta = cloneBytes(data) }) +} + +func (r *MemoryRepository) ReadStats(ctx context.Context, id NodeId) (*NodeStats, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil || node.stats == nil { + return nil, ErrNotExist + } + return cloneStats(ctx, node.stats) +} + +func (r *MemoryRepository) WriteStats(ctx context.Context, id NodeId, stats *NodeStats) error { + copyStats, err := cloneStats(ctx, stats) + if err != nil { + return err + } + return r.updateNode(ctx, id, func(node *memoryNode) { node.stats = copyStats }) +} + +func (r *MemoryRepository) updateNode(ctx context.Context, id NodeId, update func(*memoryNode)) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + update(node) + return nil +} + +func (r *MemoryRepository) ReadMetaBatch(ctx context.Context, ids []NodeId) (map[string][]byte, error) { + out := make(map[string][]byte) + for _, id := range ids { + raw, err := r.ReadMeta(ctx, id) + if err == nil { + out[id.Path()] = raw + } else if !errors.Is(err, ErrNotExist) { + return nil, err + } + } + return out, nil +} + +func (r *MemoryRepository) ReadStatsBatch(ctx context.Context, ids []NodeId) (map[string]*NodeStats, error) { + out := make(map[string]*NodeStats) + for _, id := range ids { + stats, err := r.ReadStats(ctx, id) + if err == nil { + out[id.Path()] = stats + } else if !errors.Is(err, ErrNotExist) { + return nil, err + } + } + return out, nil +} + +func (r *MemoryRepository) GetIndex(ctx context.Context, name string) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + data, ok := r.indexes[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *MemoryRepository) WriteIndex(ctx context.Context, name string, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.indexes[name] = cloneBytes(data) + return nil +} + +func (r *MemoryRepository) ListIndexes(ctx context.Context) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + names := make([]string, 0, len(r.indexes)) + for name := range r.indexes { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *MemoryRepository) ClearIndexes(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.indexes = make(map[string][]byte) + return nil +} + +func (r *MemoryRepository) ReadSettings(ctx context.Context) (*Settings, error) { + raw, err := r.ReadSettingsDocument(ctx) + if err != nil { + return nil, err + } + return ParseKegSettings(raw) +} + +func (r *MemoryRepository) WriteSettings(ctx context.Context, settings *Settings) error { + raw, err := settings.ToYAML() + if err != nil { + return err + } + return r.WriteSettingsDocument(ctx, raw) +} + +func (r *MemoryRepository) ReadSettingsDocument(ctx context.Context) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + if r.settings == nil { + return nil, ErrNotExist + } + return cloneBytes(r.settings), nil +} + +func (r *MemoryRepository) WriteSettingsDocument(ctx context.Context, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.settings = cloneBytes(data) + return nil +} + +func (r *MemoryRepository) ListSchemas(ctx context.Context) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + names := make([]string, 0, len(r.schemas)) + for name := range r.schemas { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *MemoryRepository) ReadSchema(ctx context.Context, name string) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + data, ok := r.schemas[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *MemoryRepository) CreateSchema(ctx context.Context, name string, data []byte) error { + if _, err := SchemaFilename(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.schemas[name]; ok { + return ErrExist + } + r.schemas[name] = cloneBytes(data) + return nil +} + +func (r *MemoryRepository) WriteSchema(ctx context.Context, name string, data []byte) error { + if _, err := SchemaFilename(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.schemas[name] = cloneBytes(data) + return nil +} + +func (r *MemoryRepository) DeleteSchema(ctx context.Context, name string) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.schemas[name]; !ok { + return ErrNotExist + } + delete(r.schemas, name) + return nil +} + +func (r *MemoryRepository) ListFiles(ctx context.Context, id NodeId) ([]string, error) { + return r.listAssets(ctx, id, false) +} + +func (r *MemoryRepository) ListImages(ctx context.Context, id NodeId) ([]string, error) { + return r.listAssets(ctx, id, true) +} + +func (r *MemoryRepository) listAssets(ctx context.Context, id NodeId, images bool) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + names := make([]string, 0, len(assets)) + for name := range assets { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *MemoryRepository) ReadFile(ctx context.Context, id NodeId, name string) ([]byte, error) { + return r.readAsset(ctx, id, name, false) +} + +func (r *MemoryRepository) ReadImage(ctx context.Context, id NodeId, name string) ([]byte, error) { + return r.readAsset(ctx, id, name, true) +} + +func (r *MemoryRepository) readAsset(ctx context.Context, id NodeId, name string, images bool) ([]byte, error) { + if err := ValidateAssetName(name); err != nil { + return nil, err + } + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + data, ok := assets[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *MemoryRepository) WriteFile(ctx context.Context, id NodeId, name string, data []byte) error { + return r.writeAsset(ctx, id, name, data, false) +} + +func (r *MemoryRepository) WriteImage(ctx context.Context, id NodeId, name string, data []byte) error { + return r.writeAsset(ctx, id, name, data, true) +} + +func (r *MemoryRepository) writeAsset(ctx context.Context, id NodeId, name string, data []byte, images bool) error { + if err := ValidateAssetName(name); err != nil { + return err + } + return r.updateNode(ctx, id, func(node *memoryNode) { + if images { + node.images[name] = cloneBytes(data) + } else { + node.files[name] = cloneBytes(data) + } + }) +} + +func (r *MemoryRepository) DeleteFile(ctx context.Context, id NodeId, name string) error { + return r.deleteAsset(ctx, id, name, false) +} + +func (r *MemoryRepository) DeleteImage(ctx context.Context, id NodeId, name string) error { + return r.deleteAsset(ctx, id, name, true) +} + +func (r *MemoryRepository) deleteAsset(ctx context.Context, id NodeId, name string, images bool) error { + if err := ValidateAssetName(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + if _, ok := assets[name]; !ok { + return ErrNotExist + } + delete(assets, name) + return nil +} + +func (r *MemoryRepository) WithKegAtomicWrite(ctx context.Context, fn func(context.Context) error) error { + return r.WithKegWrite(ctx, func(writeCtx context.Context) error { + r.mu.Lock() + backup := r.cloneStateLocked() + r.mu.Unlock() + if err := fn(writeCtx); err != nil { + r.mu.Lock() + r.restoreStateLocked(backup) + r.mu.Unlock() + return err + } + return nil + }) +} + +type memoryState struct { + nodes map[NodeId]*memoryNode + reserved map[NodeId]struct{} + indexes map[string][]byte + settings []byte + schemas map[string][]byte + snaps map[NodeId][]memorySnapshot + locks map[NodeId]LockInfo +} + +func (r *MemoryRepository) cloneStateLocked() memoryState { + state := memoryState{ + nodes: make(map[NodeId]*memoryNode), reserved: make(map[NodeId]struct{}), + indexes: make(map[string][]byte), settings: cloneBytes(r.settings), + schemas: make(map[string][]byte), snaps: make(map[NodeId][]memorySnapshot), + locks: make(map[NodeId]LockInfo), + } + for id, node := range r.nodes { + state.nodes[id] = cloneMemoryNode(node) + } + for id := range r.reserved { + state.reserved[id] = struct{}{} + } + for name, data := range r.indexes { + state.indexes[name] = cloneBytes(data) + } + for name, data := range r.schemas { + state.schemas[name] = cloneBytes(data) + } + for id, snaps := range r.snaps { + state.snaps[id] = cloneMemorySnapshots(snaps) + } + for id, info := range r.locks { + state.locks[id] = info + } + return state +} + +func (r *MemoryRepository) restoreStateLocked(state memoryState) { + r.nodes, r.reserved, r.indexes = state.nodes, state.reserved, state.indexes + r.settings, r.schemas, r.snaps, r.locks = state.settings, state.schemas, state.snaps, state.locks +} + +func (r *MemoryRepository) AppendSnapshot(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.nodes[id]; !ok { + return Snapshot{}, ErrNotExist + } + history := r.snaps[id] + parent := RevisionID(0) + if len(history) > 0 { + parent = history[len(history)-1].snapshot.ID + } + if in.ExpectedParent != parent { + return Snapshot{}, ErrConflict + } + content := cloneBytes(in.Content.Data) + if in.Content.Kind == SnapshotContentKindPatch { + var base []byte + for _, record := range history { + if record.snapshot.ID == in.Content.Base { + base = record.content + break + } + } + var err error + content, err = applySnapshotPatch(r.runtime.Hasher(), base, in.Content.Data) + if err != nil { + // Repository contract tests may provide already materialized content; + // LocalKeg supplies encoded line-patch data in normal operation. + content = cloneBytes(in.Content.Data) + } + } + createdAt := in.CreatedAt + if createdAt.IsZero() { + createdAt = r.runtime.Clock().Now() + } + statsBytes, err := snapshotStatsToBytes(in.Stats) + if err != nil { + return Snapshot{}, err + } + contentHash, metaHash, statsHash := snapshotWriteHashes(r.runtime, content, in.Meta, statsBytes) + snapshot := Snapshot{ + ID: RevisionID(len(history) + 1), Node: id, Parent: parent, + CreatedAt: createdAt, Message: in.Message, ContentHash: contentHash, + MetaHash: metaHash, StatsHash: statsHash, + IsCheckpoint: in.Content.Kind != SnapshotContentKindPatch, + } + stats, err := cloneStats(ctx, in.Stats) + if err != nil { + return Snapshot{}, err + } + r.snaps[id] = append(history, memorySnapshot{snapshot: snapshot, content: content, meta: cloneBytes(in.Meta), stats: stats}) + return snapshot, nil +} + +func (r *MemoryRepository) GetSnapshot(ctx context.Context, id NodeId, rev RevisionID, opts SnapshotReadOptions) (Snapshot, []byte, []byte, *NodeStats, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return Snapshot{}, nil, nil, nil, err + } + record, ok := r.snapshotLocked(id, rev) + if !ok { + return Snapshot{}, nil, nil, nil, ErrNotExist + } + var content []byte + if opts.ResolveContent { + content = cloneBytes(record.content) + } + stats, err := cloneStats(ctx, record.stats) + return record.snapshot, content, cloneBytes(record.meta), stats, err +} + +func (r *MemoryRepository) ListSnapshots(ctx context.Context, id NodeId) ([]Snapshot, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + history := r.snaps[id] + out := make([]Snapshot, len(history)) + for i := range history { + out[i] = history[i].snapshot + } + return out, nil +} + +func (r *MemoryRepository) ReadContentAt(ctx context.Context, id NodeId, rev RevisionID) ([]byte, error) { + _, content, _, _, err := r.GetSnapshot(ctx, id, rev, SnapshotReadOptions{ResolveContent: true}) + return content, err +} + +func (r *MemoryRepository) RestoreSnapshot(ctx context.Context, id NodeId, rev RevisionID, createRestoreSnapshot bool) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.snapshotLocked(id, rev) + if !ok { + return ErrNotExist + } + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + node.content = cloneBytes(record.content) + node.meta = cloneBytes(record.meta) + node.stats, _ = cloneStats(ctx, record.stats) + if createRestoreSnapshot { + history := r.snaps[id] + parent := history[len(history)-1].snapshot.ID + statsBytes, _ := snapshotStatsToBytes(record.stats) + contentHash, metaHash, statsHash := snapshotWriteHashes(r.runtime, record.content, record.meta, statsBytes) + snapshot := Snapshot{ + ID: RevisionID(len(history) + 1), Node: id, Parent: parent, + CreatedAt: r.runtime.Clock().Now(), Message: fmt.Sprintf("restore from rev %d", rev), + ContentHash: contentHash, MetaHash: metaHash, StatsHash: statsHash, IsCheckpoint: true, + } + r.snaps[id] = append(history, memorySnapshot{snapshot: snapshot, content: cloneBytes(record.content), meta: cloneBytes(record.meta), stats: record.stats}) + } + return nil +} + +func (r *MemoryRepository) snapshotLocked(id NodeId, rev RevisionID) (memorySnapshot, bool) { + for _, record := range r.snaps[id] { + if record.snapshot.ID == rev { + return record, true + } + } + return memorySnapshot{}, false +} + +func (r *MemoryRepository) corruptLatestSnapshot(id NodeId, mutate func(*Snapshot, *[]byte)) error { + r.mu.Lock() + defer r.mu.Unlock() + history := r.snaps[id] + if len(history) == 0 { + return ErrNotExist + } + latest := &history[len(history)-1] + mutate(&latest.snapshot, &latest.content) + r.snaps[id] = history + return nil +} + +func (r *MemoryRepository) AcquireLock(ctx context.Context, id NodeId) (LockToken, error) { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + r.mu.Lock() + info := r.locks[id] + if info.Token == "" || info.IsStale(r.runtime.Clock().Now()) { + token := generateLockToken() + r.locks[id] = LockInfo{Token: token, AcquiredAt: r.runtime.Clock().Now(), TTLSeconds: int(DefaultLockTTL.Seconds()), Holder: "memory-test"} + r.mu.Unlock() + return token, nil + } + r.mu.Unlock() + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) + case <-ticker.C: + } + } +} + +func (r *MemoryRepository) ReleaseLock(ctx context.Context, id NodeId, token LockToken) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + info, ok := r.locks[id] + if !ok || info.Token == "" { + return ErrNotLocked + } + if info.Token != token { + return ErrLockTokenMismatch + } + delete(r.locks, id) + return nil +} + +func (r *MemoryRepository) LockStatus(ctx context.Context, id NodeId) (LockInfo, error) { + if err := ctx.Err(); err != nil { + return LockInfo{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + info := r.locks[id] + if info.Token == "" || info.IsStale(r.runtime.Clock().Now()) { + delete(r.locks, id) + return LockInfo{}, nil + } + return info, nil +} + +func (r *MemoryRepository) ForceReleaseLock(ctx context.Context, id NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.locks, id) + return nil +} + +func (r *MemoryRepository) Watch(ctx context.Context, ids ...NodeId) (<-chan NodeEvent, error) { + watcher := &memoryWatcher{ids: make(map[NodeId]struct{}), ch: make(chan NodeEvent, 32)} + for _, id := range ids { + watcher.ids[id] = struct{}{} + } + r.watchersMu.Lock() + r.watchers[watcher] = struct{}{} + r.watchersMu.Unlock() + go func() { + <-ctx.Done() + r.watchersMu.Lock() + if _, ok := r.watchers[watcher]; ok { + delete(r.watchers, watcher) + close(watcher.ch) + } + r.watchersMu.Unlock() + }() + return watcher.ch, nil +} + +func (r *MemoryRepository) Emit(event NodeEvent) { + r.watchersMu.Lock() + defer r.watchersMu.Unlock() + for watcher := range r.watchers { + if len(watcher.ids) > 0 { + if _, ok := watcher.ids[event.NodeID]; !ok { + continue + } + } + select { + case watcher.ch <- event: + default: + } + } +} + +func newMemoryNode() *memoryNode { + return &memoryNode{files: make(map[string][]byte), images: make(map[string][]byte)} +} + +func cloneBytes(data []byte) []byte { + return append([]byte(nil), data...) +} + +type snapshotHasher interface { + Hash([]byte) string +} + +type textPatch struct { + BaseHash string `json:"base_hash,omitempty"` + Ops []textPatchOp `json:"ops"` +} + +type textPatchOp struct { + Type string `json:"type"` + Count int `json:"count,omitempty"` + Lines []string `json:"lines,omitempty"` +} + +func applySnapshotPatch(hasher snapshotHasher, base, raw []byte) ([]byte, error) { + var patch textPatch + if err := json.Unmarshal(raw, &patch); err != nil { + return nil, err + } + if patch.BaseHash != "" && patch.BaseHash != hashSnapshotBytes(hasher, base) { + return nil, ErrConflict + } + lines := strings.SplitAfter(string(base), "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + var out strings.Builder + index := 0 + for _, op := range patch.Ops { + switch op.Type { + case "equal": + if index+op.Count > len(lines) { + return nil, ErrInvalid + } + for _, line := range lines[index : index+op.Count] { + out.WriteString(line) + } + index += op.Count + case "delete": + if index+op.Count > len(lines) { + return nil, ErrInvalid + } + index += op.Count + case "insert": + for _, line := range op.Lines { + out.WriteString(line) + } + default: + return nil, ErrInvalid + } + } + if index != len(lines) { + return nil, ErrInvalid + } + return []byte(out.String()), nil +} + +func hashSnapshotBytes(hasher snapshotHasher, data []byte) string { + if hasher == nil || len(data) == 0 { + return "" + } + return hasher.Hash(data) +} + +func snapshotStatsToBytes(stats *NodeStats) ([]byte, error) { + if stats == nil { + return nil, nil + } + return stats.ToJSON() +} + +func snapshotWriteHashes(rt *toolkit.Runtime, content, meta, stats []byte) (string, string, string) { + return hashSnapshotBytes(rt.Hasher(), content), hashSnapshotBytes(rt.Hasher(), meta), hashSnapshotBytes(rt.Hasher(), stats) +} + +func generateLockToken() LockToken { + var uuid [16]byte + _, _ = rand.Read(uuid[:]) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + return LockToken(fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])) +} + +func cloneMemoryNode(node *memoryNode) *memoryNode { + copyNode := newMemoryNode() + copyNode.content, copyNode.meta = cloneBytes(node.content), cloneBytes(node.meta) + copyNode.stats, _ = cloneStats(context.Background(), node.stats) + for name, data := range node.files { + copyNode.files[name] = cloneBytes(data) + } + for name, data := range node.images { + copyNode.images[name] = cloneBytes(data) + } + return copyNode +} + +func cloneMemorySnapshots(in []memorySnapshot) []memorySnapshot { + out := make([]memorySnapshot, len(in)) + for i := range in { + out[i] = memorySnapshot{snapshot: in[i].snapshot, content: cloneBytes(in[i].content), meta: cloneBytes(in[i].meta)} + out[i].stats, _ = cloneStats(context.Background(), in[i].stats) + } + return out +} + +func cloneStats(ctx context.Context, stats *NodeStats) (*NodeStats, error) { + if stats == nil { + return nil, nil + } + raw, err := stats.ToJSON() + if err != nil { + return nil, err + } + return ParseStats(ctx, raw) +} + +var ( + _ Repository = (*MemoryRepository)(nil) + _ RepositorySettingsDocuments = (*MemoryRepository)(nil) + _ RepositoryAtomicWrite = (*MemoryRepository)(nil) + _ RepositoryConcurrentAccess = (*MemoryRepository)(nil) + _ RepositoryBatchRead = (*MemoryRepository)(nil) + _ RepositoryFiles = (*MemoryRepository)(nil) + _ RepositoryImages = (*MemoryRepository)(nil) + _ RepositorySchemas = (*MemoryRepository)(nil) + _ RepositorySnapshots = (*MemoryRepository)(nil) + _ RepositoryLock = (*MemoryRepository)(nil) + _ RepositoryEvents = (*MemoryRepository)(nil) +) diff --git a/package.json b/package.json deleted file mode 100644 index 0ecf2464..00000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "dependencies": { - "@sigma/node-border": "^3.0.0", - "graphology": "^0.26.0", - "graphology-layout-forceatlas2": "^0.10.1", - "sigma": "^3.0.2" - }, - "name": "tapper", - "module": "index.ts", - "type": "module", - "private": true, - "devDependencies": { - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/pkg/cli/assets.go b/pkg/cli/assets.go deleted file mode 100644 index bed7c534..00000000 --- a/pkg/cli/assets.go +++ /dev/null @@ -1,8 +0,0 @@ -package cli - -import _ "embed" - -// graphBundle is the compiled self-contained graph renderer. -// -//go:embed assets/graph.bundle.js -var graphBundle []byte diff --git a/pkg/cli/assets/graph.bundle.js b/pkg/cli/assets/graph.bundle.js deleted file mode 100644 index 4dcc86cd..00000000 --- a/pkg/cli/assets/graph.bundle.js +++ /dev/null @@ -1,324 +0,0 @@ -var v6=Object.create;var{getPrototypeOf:G6,defineProperty:eJ,getOwnPropertyNames:O6}=Object;var f6=Object.prototype.hasOwnProperty;var TJ=(J,K,Q)=>{Q=J!=null?v6(G6(J)):{};let Z=K||!J||!J.__esModule?eJ(Q,"default",{value:J,enumerable:!0}):Q;for(let q of O6(J))if(!f6.call(Z,q))eJ(Z,q,{get:()=>J[q],enumerable:!0});return Z};var E0=(J,K)=>()=>(K||J((K={exports:{}}).exports,K),K.exports);var qJ=E0((c8,CQ)=>{CQ.exports=function(K){return K!==null&&typeof K==="object"&&typeof K.addUndirectedEdgeWithKey==="function"&&typeof K.dropNode==="function"&&typeof K.multi==="boolean"}});var IQ=E0((t7)=>{function r7(J){if(typeof J!=="number"||isNaN(J))return 1;return J}function a7(J,K){var Q={},Z=function(V){if(typeof V>"u")return K;return V};if(typeof K==="function")Z=K;var q=function(V){return Z(V[J])},W=function(){return Z(void 0)};if(typeof J==="string")Q.fromAttributes=q,Q.fromGraph=function(V,z){return q(V.getNodeAttributes(z))},Q.fromEntry=function(V,z){return q(z)};else if(typeof J==="function")Q.fromAttributes=function(){throw Error("graphology-utils/getters/createNodeValueGetter: irrelevant usage.")},Q.fromGraph=function(V,z){return Z(J(z,V.getNodeAttributes(z)))},Q.fromEntry=function(V,z){return Z(J(V,z))};else Q.fromAttributes=W,Q.fromGraph=W,Q.fromEntry=W;return Q}function PQ(J,K){var Q={},Z=function(V){if(typeof V>"u")return K;return V};if(typeof K==="function")Z=K;var q=function(V){return Z(V[J])},W=function(){return Z(void 0)};if(typeof J==="string")Q.fromAttributes=q,Q.fromGraph=function(V,z){return q(V.getEdgeAttributes(z))},Q.fromEntry=function(V,z){return q(z)},Q.fromPartialEntry=Q.fromEntry,Q.fromMinimalEntry=Q.fromEntry;else if(typeof J==="function")Q.fromAttributes=function(){throw Error("graphology-utils/getters/createEdgeValueGetter: irrelevant usage.")},Q.fromGraph=function(V,z){var B=V.extremities(z);return Z(J(z,V.getEdgeAttributes(z),B[0],B[1],V.getNodeAttributes(B[0]),V.getNodeAttributes(B[1]),V.isUndirected(z)))},Q.fromEntry=function(V,z,B,H,Y,$,X){return Z(J(V,z,B,H,Y,$,X))},Q.fromPartialEntry=function(V,z,B,H){return Z(J(V,z,B,H))},Q.fromMinimalEntry=function(V,z){return Z(J(V,z))};else Q.fromAttributes=W,Q.fromGraph=W,Q.fromEntry=W,Q.fromMinimalEntry=W;return Q}t7.createNodeValueGetter=a7;t7.createEdgeValueGetter=PQ;t7.createEdgeWeightGetter=function(J){return PQ(J,r7)}});var GQ=E0((p8,vQ)=>{var l=0,_=1,b=2,N=3,P0=4,I0=5,y=6,kQ=7,WJ=8,SQ=9,Z9=0,K9=1,q9=2,d=0,B0=1,a=2,M0=3,R0=4,m=5,Z0=6,F0=7,L0=8,RQ=3,j0=10,W9=3,o=9,NJ=10;vQ.exports=function(K,Q,Z){var q,W,V,z,B,H,Y,$,X,j,w=Q.length,L=Z.length,A=K.adjustSizes,G=K.barnesHutTheta*K.barnesHutTheta,k,T,P,O,M,I,R,U=[];for(V=0;V$0)h-=(T0-$0)/2,J0=h+T0;else u-=($0-T0)/2,c=u+$0;U[0+d]=-1,U[0+B0]=(u+c)/2,U[0+a]=(h+J0)/2,U[0+M0]=Math.max(c-u,J0-h),U[0+R0]=-1,U[0+m]=-1,U[0+Z0]=0,U[0+F0]=0,U[0+L0]=0,q=1;for(V=0;V=0){if(Q[V+l]=0)if(I=Math.pow(Q[V+l]-U[W+F0],2)+Math.pow(Q[V+_]-U[W+L0],2),j=U[W+M0],4*j*j/I0)R=T*Q[V+y]*U[W+Z0]/I,Q[V+b]+=P*R,Q[V+N]+=O*R;else if(I<0)R=-T*Q[V+y]*U[W+Z0]/Math.sqrt(I),Q[V+b]+=P*R,Q[V+N]+=O*R}else if(I>0)R=T*Q[V+y]*U[W+Z0]/I,Q[V+b]+=P*R,Q[V+N]+=O*R;if(W=U[W+R0],W<0)break;continue}else{W=U[W+m];continue}else{if(H=U[W+d],H>=0&&H!==V){if(P=Q[V+l]-Q[H+l],O=Q[V+_]-Q[H+_],I=P*P+O*O,A===!0){if(I>0)R=T*Q[V+y]*Q[H+y]/I,Q[V+b]+=P*R,Q[V+N]+=O*R;else if(I<0)R=-T*Q[V+y]*Q[H+y]/Math.sqrt(I),Q[V+b]+=P*R,Q[V+N]+=O*R}else if(I>0)R=T*Q[V+y]*Q[H+y]/I,Q[V+b]+=P*R,Q[V+N]+=O*R}if(W=U[W+R0],W<0)break;continue}}}else{T=K.scalingRatio;for(z=0;z0)R=T*Q[z+y]*Q[B+y]/I/I,Q[z+b]+=P*R,Q[z+N]+=O*R,Q[B+b]-=P*R,Q[B+N]-=O*R;else if(I<0)R=100*T*Q[z+y]*Q[B+y],Q[z+b]+=P*R,Q[z+N]+=O*R,Q[B+b]-=P*R,Q[B+N]-=O*R}else if(I=Math.sqrt(P*P+O*O),I>0)R=T*Q[z+y]*Q[B+y]/I/I,Q[z+b]+=P*R,Q[z+N]+=O*R,Q[B+b]-=P*R,Q[B+N]-=O*R}X=K.gravity/K.scalingRatio,T=K.scalingRatio;for(V=0;V0)R=T*Q[V+y]*X}else if(I>0)R=T*Q[V+y]*X/I;Q[V+b]-=P*R,Q[V+N]-=O*R}T=1*(K.outboundAttractionDistribution?k:1);for(Y=0;Y0)R=-T*M*Math.log(1+I)/I/Q[z+y]}else if(I>0)R=-T*M*Math.log(1+I)/I}else if(K.outboundAttractionDistribution){if(I>0)R=-T*M/Q[z+y]}else if(I>0)R=-T*M}else if(I=Math.sqrt(Math.pow(P,2)+Math.pow(O,2)),K.linLogMode){if(K.outboundAttractionDistribution){if(I>0)R=-T*M*Math.log(1+I)/I/Q[z+y]}else if(I>0)R=-T*M*Math.log(1+I)/I}else if(K.outboundAttractionDistribution)I=1,R=-T*M/Q[z+y];else I=1,R=-T*M;if(I>0)Q[z+b]+=P*R,Q[z+N]+=O*R,Q[B+b]-=P*R,Q[B+N]-=O*R}var W0,Q0,K0,Y0,r,x0;if(A===!0){for(V=0;VNJ)Q[V+b]=Q[V+b]*NJ/W0,Q[V+N]=Q[V+N]*NJ/W0;Q0=Q[V+y]*Math.sqrt((Q[V+P0]-Q[V+b])*(Q[V+P0]-Q[V+b])+(Q[V+I0]-Q[V+N])*(Q[V+I0]-Q[V+N])),K0=Math.sqrt((Q[V+P0]+Q[V+b])*(Q[V+P0]+Q[V+b])+(Q[V+I0]+Q[V+N])*(Q[V+I0]+Q[V+N]))/2,Y0=0.1*Math.log(1+K0)/(1+Math.sqrt(Q0)),r=Q[V+l]+Q[V+b]*(Y0/K.slowDown),Q[V+l]=r,x0=Q[V+_]+Q[V+N]*(Y0/K.slowDown),Q[V+_]=x0}}else for(V=0;V{var i0=10,OQ=3;V9.assign=function(J){J=J||{};var K=Array.prototype.slice.call(arguments).slice(1),Q,Z,q;for(Q=0,q=K.length;Q=0))return{message:"the `scalingRatio` setting should be a number >= 0."};if("strongGravityMode"in J&&typeof J.strongGravityMode!=="boolean")return{message:"the `strongGravityMode` setting should be a boolean."};if("gravity"in J&&!(typeof J.gravity==="number"&&J.gravity>=0))return{message:"the `gravity` setting should be a number >= 0."};if("slowDown"in J&&!(typeof J.slowDown==="number"||J.slowDown>=0))return{message:"the `slowDown` setting should be a number >= 0."};if("barnesHutOptimize"in J&&typeof J.barnesHutOptimize!=="boolean")return{message:"the `barnesHutOptimize` setting should be a boolean."};if("barnesHutTheta"in J&&!(typeof J.barnesHutTheta==="number"&&J.barnesHutTheta>=0))return{message:"the `barnesHutTheta` setting should be a number >= 0."};return null};V9.graphToByteArrays=function(J,K){var{order:Q,size:Z}=J,q={},W,V=new Float32Array(Q*i0),z=new Float32Array(Z*OQ);return W=0,J.forEachNode(function(B,H){q[B]=W,V[W]=H.x,V[W+1]=H.y,V[W+2]=0,V[W+3]=0,V[W+4]=0,V[W+5]=0,V[W+6]=1,V[W+7]=1,V[W+8]=H.size||1,V[W+9]=H.fixed?1:0,W+=i0}),W=0,J.forEachEdge(function(B,H,Y,$,X,j,w){var L=q[Y],A=q[$],G=K(B,H,Y,$,X,j,w);V[L+6]+=G,V[A+6]+=G,z[W]=L,z[W+1]=A,z[W+2]=G,W+=OQ}),{nodes:V,edges:z}};V9.assignLayoutChanges=function(J,K,Q){var Z=0;J.updateEachNodeAttributes(function(q,W){return W.x=K[Z],W.y=K[Z+1],Z+=i0,Q?Q(q,W):W})};V9.readGraphPositions=function(J,K){var Q=0;J.forEachNode(function(Z,q){K[Q]=q.x,K[Q+1]=q.y,Q+=i0})};V9.collectLayoutChanges=function(J,K,Q){var Z=J.nodes(),q={};for(var W=0,V=0,z=K.length;W{MQ.exports={linLogMode:!1,outboundAttractionDistribution:!1,adjustSizes:!1,edgeWeightInfluence:1,scalingRatio:1,strongGravityMode:!1,gravity:1,slowDown:1,barnesHutOptimize:!1,barnesHutTheta:0.5}});var xQ=E0((n8,yQ)=>{var U9=qJ(),w9=IQ().createEdgeWeightGetter,F9=GQ(),o0=fQ(),L9=bQ();function NQ(J,K,Q){if(!U9(K))throw Error("graphology-layout-forceatlas2: the given graph is not a valid graphology instance.");if(typeof Q==="number")Q={iterations:Q};var Z=Q.iterations;if(typeof Z!=="number")throw Error("graphology-layout-forceatlas2: invalid number of iterations.");if(Z<=0)throw Error("graphology-layout-forceatlas2: you should provide a positive number of iterations.");var q=w9("getEdgeWeight"in Q?Q.getEdgeWeight:"weight").fromEntry,W=typeof Q.outputReducer==="function"?Q.outputReducer:null,V=o0.assign({},L9,Q.settings),z=o0.validateSettings(V);if(z)throw Error("graphology-layout-forceatlas2: "+z.message);var B=o0.graphToByteArrays(K,q),H;for(H=0;H2000,strongGravityMode:!0,gravity:0.05,scalingRatio:10,slowDown:1+Math.log(K)}}var yJ=NQ.bind(null,!1);yJ.assign=NQ.bind(null,!0);yJ.inferSettings=T9;yQ.exports=yJ});var CJ=Symbol.for,O0=Symbol("kCapture"),KQ=CJ("events.errorMonitor"),M6=Symbol("events.maxEventTargetListeners"),b6=Symbol("events.maxEventTargetListenersWarned"),JQ=CJ("nodejs.rejection"),N6=CJ("nodejs.rejection"),QQ=Array.prototype.slice,f0=10,w0=function(J){if(this._events===void 0||this._events===this.__proto__._events)this._events={__proto__:null},this._eventsCount=0;if(this._maxListeners??=void 0,this[O0]=J?.captureRejections?Boolean(J?.captureRejections):D[O0])this.emit=D6},D=w0.prototype={};D._events=void 0;D._eventsCount=0;D._maxListeners=void 0;D.setMaxListeners=function(J){return PJ(J,"setMaxListeners",0),this._maxListeners=J,this};D.constructor=w0;D.getMaxListeners=function(){return this?._maxListeners??f0};function qQ(J,K){var{_events:Q}=J;if(K[0]??=Error("Unhandled error."),!Q)throw K[0];var Z=Q[KQ];if(Z)for(var q of QQ.call(Z))q.apply(J,K);var W=Q.error;if(!W)throw K[0];for(var q of QQ.call(W))q.apply(J,K);return!0}function y6(J,K,Q,Z){K.then(void 0,function(q){queueMicrotask(()=>x6(J,q,Q,Z))})}function x6(J,K,Q,Z){if(typeof J[JQ]==="function")J[JQ](K,Q,...Z);else try{J[O0]=!1,J.emit("error",K)}finally{J[O0]=!0}}var E6=function(J,...K){if(J==="error")return qQ(this,K);var{_events:Q}=this;if(Q===void 0)return!1;var Z=Q[J];if(Z===void 0)return!1;let q=Z.length>1?Z.slice():Z;for(let W=0,{length:V}=q;W1?Z.slice():Z;for(let W=0,{length:V}=q;W0&&Z.length>q&&!Z.warned)WQ(this,J,Z)}return this};D.on=D.addListener;D.prependListener=function(J,K){s0(K);var Q=this._events;if(!Q)Q=this._events={__proto__:null},this._eventsCount=0;else if(Q.newListener)this.emit("newListener",J,K.listener??K);var Z=Q[J];if(!Z)Q[J]=[K],this._eventsCount++;else{Z.unshift(K);var q=this._maxListeners??f0;if(q>0&&Z.length>q&&!Z.warned)WQ(this,J,Z)}return this};function WQ(J,K,Q){Q.warned=!0;let Z=Error(`Possible EventEmitter memory leak detected. ${Q.length} ${String(K)} listeners added to [${J.constructor.name}]. Use emitter.setMaxListeners() to increase limit`);Z.name="MaxListenersExceededWarning",Z.emitter=J,Z.type=K,Z.count=Q.length,console.warn(Z)}function VQ(J,K,...Q){this.removeListener(J,K),K.apply(this,Q)}D.once=function(J,K){s0(K);let Q=VQ.bind(this,J,K);return Q.listener=K,this.addListener(J,Q),this};D.prependOnceListener=function(J,K){s0(K);let Q=VQ.bind(this,J,K);return Q.listener=K,this.prependListener(J,Q),this};D.removeListener=function(J,K){s0(K);var{_events:Q}=this;if(!Q)return this;var Z=Q[J];if(!Z)return this;var q=Z.length;let W=-1;for(let V=q-1;V>=0;V--)if(Z[V]===K||Z[V].listener===K){W=V;break}if(W<0)return this;if(W===0)Z.shift();else Z.splice(W,1);if(Z.length===0)delete Q[J],this._eventsCount--;return this};D.off=D.removeListener;D.removeAllListeners=function(J){var{_events:K}=this;if(J&&K){if(K[J])delete K[J],this._eventsCount--}else this._events={__proto__:null};return this};D.listeners=function(J){var{_events:K}=this;if(!K)return[];var Q=K[J];if(!Q)return[];return Q.map((Z)=>Z.listener??Z)};D.rawListeners=function(J){var{_events:K}=this;if(!K)return[];var Q=K[J];if(!Q)return[];return Q.slice()};D.listenerCount=function(J){var{_events:K}=this;if(!K)return 0;return K[J]?.length??0};D.eventNames=function(){return this._eventsCount>0?Reflect.ownKeys(this._events):[]};D[O0]=!1;function _6(J,K,Q){var Z=Q?.signal;if(zQ(Z,"options.signal"),Z?.aborted)throw new AJ(void 0,{cause:Z?.reason});let{resolve:q,reject:W,promise:V}=$newPromiseCapability(Promise),z=(Y)=>{if(J.removeListener(K,B),Z!=null)JJ(Z,"abort",H);W(Y)},B=(...Y)=>{if(typeof J.removeListener==="function")J.removeListener("error",z);if(Z!=null)JJ(Z,"abort",H);q(Y)};if(ZQ(J,K,B,{once:!0}),K!=="error"&&typeof J.once==="function")J.once("error",z);function H(){JJ(J,K,B),JJ(J,"error",z),W(new AJ(void 0,{cause:Z?.reason}))}if(Z!=null)ZQ(Z,"abort",H,{once:!0});return V}function u6(J,K){return J.listeners(K)}function h6(J,...K){PJ(J,"setMaxListeners",0);var Q;if(K&&(Q=K.length))for(let Z=0;ZZ||(Q!=null||Z!=null)&&Number.isNaN(J))throw c6(K,`${Q!=null?`>= ${Q}`:""}${Q!=null&&Z!=null?" && ":""}${Z!=null?`<= ${Z}`:""}`,J)}function s0(J){if(typeof J!=="function")throw TypeError("The listener must be a function")}function l6(J,K){if(typeof J!=="boolean")throw D0(K,"boolean",J)}function p6(J){return J?._maxListeners??f0}function g6(J,K){if(J===void 0)throw D0("signal","AbortSignal",J);if(zQ(J,"signal"),typeof K!=="function")throw D0("listener","function",K);let Q;if(J.aborted)queueMicrotask(()=>K());else J.addEventListener("abort",K,{__proto__:null,once:!0}),Q=()=>{J.removeEventListener("abort",K)};return{__proto__:null,[Symbol.dispose](){Q?.()}}}Object.defineProperties(w0,{captureRejections:{get(){return D[O0]},set(J){l6(J,"EventEmitter.captureRejections"),D[O0]=J},enumerable:!0},defaultMaxListeners:{enumerable:!0,get:()=>{return f0},set:(J)=>{PJ(J,"defaultMaxListeners",0),f0=J}},kMaxEventTargetListeners:{value:M6,enumerable:!1,configurable:!1,writable:!1},kMaxEventTargetListenersWarned:{value:b6,enumerable:!1,configurable:!1,writable:!1}});Object.assign(w0,{once:_6,getEventListeners:u6,getMaxListeners:p6,setMaxListeners:h6,EventEmitter:w0,usingDomains:!1,captureRejectionSymbol:N6,errorMonitor:KQ,addAbortListener:g6,init:w0,listenerCount:m6});function s6(){let J=arguments[0];for(let K=1,Q=arguments.length;K{return J++}}function A0(){let J=arguments,K=null,Q=-1;return{[Symbol.iterator](){return this},next(){let Z=null;do{if(K===null){if(Q++,Q>=J.length)return{done:!0};K=J[Q][Symbol.iterator]()}if(Z=K.next(),Z.done){K=null;continue}break}while(!0);return Z}}}function u0(){return{[Symbol.iterator](){return this},next(){return{done:!0}}}}class ZJ extends Error{constructor(J){super();this.name="GraphError",this.message=J}}class S extends ZJ{constructor(J){super(J);if(this.name="InvalidArgumentsGraphError",typeof Error.captureStackTrace==="function")Error.captureStackTrace(this,S.prototype.constructor)}}class C extends ZJ{constructor(J){super(J);if(this.name="NotFoundGraphError",typeof Error.captureStackTrace==="function")Error.captureStackTrace(this,C.prototype.constructor)}}class f extends ZJ{constructor(J){super(J);if(this.name="UsageGraphError",typeof Error.captureStackTrace==="function")Error.captureStackTrace(this,f.prototype.constructor)}}function XQ(J,K){this.key=J,this.attributes=K,this.clear()}XQ.prototype.clear=function(){this.inDegree=0,this.outDegree=0,this.undirectedDegree=0,this.undirectedLoops=0,this.directedLoops=0,this.in={},this.out={},this.undirected={}};function jQ(J,K){this.key=J,this.attributes=K,this.clear()}jQ.prototype.clear=function(){this.inDegree=0,this.outDegree=0,this.directedLoops=0,this.in={},this.out={}};function UQ(J,K){this.key=J,this.attributes=K,this.clear()}UQ.prototype.clear=function(){this.undirectedDegree=0,this.undirectedLoops=0,this.undirected={}};function h0(J,K,Q,Z,q){this.key=K,this.attributes=q,this.undirected=J,this.source=Q,this.target=Z}h0.prototype.attach=function(){let J="out",K="in";if(this.undirected)J=K="undirected";let Q=this.source.key,Z=this.target.key;if(this.source[J][Z]=this,this.undirected&&Q===Z)return;this.target[K][Q]=this};h0.prototype.attachMulti=function(){let J="out",K="in",Q=this.source.key,Z=this.target.key;if(this.undirected)J=K="undirected";let q=this.source[J],W=q[Z];if(typeof W>"u"){if(q[Z]=this,!(this.undirected&&Q===Z))this.target[K][Q]=this;return}W.previous=this,this.next=W,q[Z]=this,this.target[K][Q]=this};h0.prototype.detach=function(){let J=this.source.key,K=this.target.key,Q="out",Z="in";if(this.undirected)Q=Z="undirected";delete this.source[Q][K],delete this.target[Z][J]};h0.prototype.detachMulti=function(){let J=this.source.key,K=this.target.key,Q="out",Z="in";if(this.undirected)Q=Z="undirected";if(this.previous===void 0)if(this.next===void 0)delete this.source[Q][K],delete this.target[Z][J];else this.next.previous=void 0,this.source[Q][K]=this.next,this.target[Z][J]=this.next;else if(this.previous.next=this.next,this.next!==void 0)this.next.previous=this.previous};var wQ=0,FQ=1,d6=2,LQ=3;function C0(J,K,Q,Z,q,W,V){let z,B,H,Y;if(Z=""+Z,Q===wQ){if(z=J._nodes.get(Z),!z)throw new C(`Graph.${K}: could not find the "${Z}" node in the graph.`);H=q,Y=W}else if(Q===LQ){if(q=""+q,B=J._edges.get(q),!B)throw new C(`Graph.${K}: could not find the "${q}" edge in the graph.`);let $=B.source.key,X=B.target.key;if(Z===$)z=B.target;else if(Z===X)z=B.source;else throw new C(`Graph.${K}: the "${Z}" node is not attached to the "${q}" edge (${$}, ${X}).`);H=W,Y=V}else{if(B=J._edges.get(Z),!B)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`);if(Q===FQ)z=B.source;else z=B.target;H=q,Y=W}return[z,H,Y]}function i6(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);return V.attributes[z]}}function o6(J,K,Q){J.prototype[K]=function(Z,q){let[W]=C0(this,K,Q,Z,q);return W.attributes}}function r6(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);return V.attributes.hasOwnProperty(z)}}function a6(J,K,Q){J.prototype[K]=function(Z,q,W,V){let[z,B,H]=C0(this,K,Q,Z,q,W,V);return z.attributes[B]=H,this.emit("nodeAttributesUpdated",{key:z.key,type:"set",attributes:z.attributes,name:B}),this}}function t6(J,K,Q){J.prototype[K]=function(Z,q,W,V){let[z,B,H]=C0(this,K,Q,Z,q,W,V);if(typeof H!=="function")throw new S(`Graph.${K}: updater should be a function.`);let Y=z.attributes,$=H(Y[B]);return Y[B]=$,this.emit("nodeAttributesUpdated",{key:z.key,type:"set",attributes:z.attributes,name:B}),this}}function e6(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);return delete V.attributes[z],this.emit("nodeAttributesUpdated",{key:V.key,type:"remove",attributes:V.attributes,name:z}),this}}function J7(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);if(!i(z))throw new S(`Graph.${K}: provided attributes are not a plain object.`);return V.attributes=z,this.emit("nodeAttributesUpdated",{key:V.key,type:"replace",attributes:V.attributes}),this}}function Q7(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);if(!i(z))throw new S(`Graph.${K}: provided attributes are not a plain object.`);return p(V.attributes,z),this.emit("nodeAttributesUpdated",{key:V.key,type:"merge",attributes:V.attributes,data:z}),this}}function Z7(J,K,Q){J.prototype[K]=function(Z,q,W){let[V,z]=C0(this,K,Q,Z,q,W);if(typeof z!=="function")throw new S(`Graph.${K}: provided updater is not a function.`);return V.attributes=z(V.attributes),this.emit("nodeAttributesUpdated",{key:V.key,type:"update",attributes:V.attributes}),this}}var K7=[{name:(J)=>`get${J}Attribute`,attacher:i6},{name:(J)=>`get${J}Attributes`,attacher:o6},{name:(J)=>`has${J}Attribute`,attacher:r6},{name:(J)=>`set${J}Attribute`,attacher:a6},{name:(J)=>`update${J}Attribute`,attacher:t6},{name:(J)=>`remove${J}Attribute`,attacher:e6},{name:(J)=>`replace${J}Attributes`,attacher:J7},{name:(J)=>`merge${J}Attributes`,attacher:Q7},{name:(J)=>`update${J}Attributes`,attacher:Z7}];function q7(J){K7.forEach(function({name:K,attacher:Q}){Q(J,K("Node"),wQ),Q(J,K("Source"),FQ),Q(J,K("Target"),d6),Q(J,K("Opposite"),LQ)})}function W7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}return W.attributes[q]}}function V7(J,K,Q){J.prototype[K]=function(Z){let q;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>1){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let W=""+Z,V=""+arguments[1];if(q=z0(this,W,V,Q),!q)throw new C(`Graph.${K}: could not find an edge for the given path ("${W}" - "${V}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,q=this._edges.get(Z),!q)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}return q.attributes}}function z7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}return W.attributes.hasOwnProperty(q)}}function B7(J,K,Q){J.prototype[K]=function(Z,q,W){let V;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>3){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let z=""+Z,B=""+q;if(q=arguments[2],W=arguments[3],V=z0(this,z,B,Q),!V)throw new C(`Graph.${K}: could not find an edge for the given path ("${z}" - "${B}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,V=this._edges.get(Z),!V)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}return V.attributes[q]=W,this.emit("edgeAttributesUpdated",{key:V.key,type:"set",attributes:V.attributes,name:q}),this}}function H7(J,K,Q){J.prototype[K]=function(Z,q,W){let V;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>3){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let z=""+Z,B=""+q;if(q=arguments[2],W=arguments[3],V=z0(this,z,B,Q),!V)throw new C(`Graph.${K}: could not find an edge for the given path ("${z}" - "${B}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,V=this._edges.get(Z),!V)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}if(typeof W!=="function")throw new S(`Graph.${K}: updater should be a function.`);return V.attributes[q]=W(V.attributes[q]),this.emit("edgeAttributesUpdated",{key:V.key,type:"set",attributes:V.attributes,name:q}),this}}function $7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}return delete W.attributes[q],this.emit("edgeAttributesUpdated",{key:W.key,type:"remove",attributes:W.attributes,name:q}),this}}function Y7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}if(!i(q))throw new S(`Graph.${K}: provided attributes are not a plain object.`);return W.attributes=q,this.emit("edgeAttributesUpdated",{key:W.key,type:"replace",attributes:W.attributes}),this}}function X7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}if(!i(q))throw new S(`Graph.${K}: provided attributes are not a plain object.`);return p(W.attributes,q),this.emit("edgeAttributesUpdated",{key:W.key,type:"merge",attributes:W.attributes,data:q}),this}}function j7(J,K,Q){J.prototype[K]=function(Z,q){let W;if(this.type!=="mixed"&&Q!=="mixed"&&Q!==this.type)throw new f(`Graph.${K}: cannot find this type of edges in your ${this.type} graph.`);if(arguments.length>2){if(this.multi)throw new f(`Graph.${K}: cannot use a {source,target} combo when asking about an edge's attributes in a MultiGraph since we cannot infer the one you want information about.`);let V=""+Z,z=""+q;if(q=arguments[2],W=z0(this,V,z,Q),!W)throw new C(`Graph.${K}: could not find an edge for the given path ("${V}" - "${z}").`)}else{if(Q!=="mixed")throw new f(`Graph.${K}: calling this method with only a key (vs. a source and target) does not make sense since an edge with this key could have the other type.`);if(Z=""+Z,W=this._edges.get(Z),!W)throw new C(`Graph.${K}: could not find the "${Z}" edge in the graph.`)}if(typeof q!=="function")throw new S(`Graph.${K}: provided updater is not a function.`);return W.attributes=q(W.attributes),this.emit("edgeAttributesUpdated",{key:W.key,type:"update",attributes:W.attributes}),this}}var U7=[{name:(J)=>`get${J}Attribute`,attacher:W7},{name:(J)=>`get${J}Attributes`,attacher:V7},{name:(J)=>`has${J}Attribute`,attacher:z7},{name:(J)=>`set${J}Attribute`,attacher:B7},{name:(J)=>`update${J}Attribute`,attacher:H7},{name:(J)=>`remove${J}Attribute`,attacher:$7},{name:(J)=>`replace${J}Attributes`,attacher:Y7},{name:(J)=>`merge${J}Attributes`,attacher:X7},{name:(J)=>`update${J}Attributes`,attacher:j7}];function w7(J){U7.forEach(function({name:K,attacher:Q}){Q(J,K("Edge"),"mixed"),Q(J,K("DirectedEdge"),"directed"),Q(J,K("UndirectedEdge"),"undirected")})}var F7=[{name:"edges",type:"mixed"},{name:"inEdges",type:"directed",direction:"in"},{name:"outEdges",type:"directed",direction:"out"},{name:"inboundEdges",type:"mixed",direction:"in"},{name:"outboundEdges",type:"mixed",direction:"out"},{name:"directedEdges",type:"directed"},{name:"undirectedEdges",type:"undirected"}];function L7(J,K,Q,Z){let q=!1;for(let W in K){if(W===Z)continue;let V=K[W];if(q=Q(V.key,V.attributes,V.source.key,V.target.key,V.source.attributes,V.target.attributes,V.undirected),J&&q)return V.key}return}function T7(J,K,Q,Z){let q,W,V,z=!1;for(let B in K){if(B===Z)continue;q=K[B];do{if(W=q.source,V=q.target,z=Q(q.key,q.attributes,W.key,V.key,W.attributes,V.attributes,q.undirected),J&&z)return q.key;q=q.next}while(q!==void 0)}return}function IJ(J,K){let Q=Object.keys(J),Z=Q.length,q,W=0;return{[Symbol.iterator](){return this},next(){do if(!q){if(W>=Z)return{done:!0};let V=Q[W++];if(V===K){q=void 0;continue}q=J[V]}else q=q.next;while(!q);return{done:!1,value:{edge:q.key,attributes:q.attributes,source:q.source.key,target:q.target.key,sourceAttributes:q.source.attributes,targetAttributes:q.target.attributes,undirected:q.undirected}}}}}function A7(J,K,Q,Z){let q=K[Q];if(!q)return;let{source:W,target:V}=q;if(Z(q.key,q.attributes,W.key,V.key,W.attributes,V.attributes,q.undirected)&&J)return q.key}function C7(J,K,Q,Z){let q=K[Q];if(!q)return;let W=!1;do{if(W=Z(q.key,q.attributes,q.source.key,q.target.key,q.source.attributes,q.target.attributes,q.undirected),J&&W)return q.key;q=q.next}while(q!==void 0);return}function kJ(J,K){let Q=J[K];if(Q.next!==void 0)return{[Symbol.iterator](){return this},next(){if(!Q)return{done:!0};let q={edge:Q.key,attributes:Q.attributes,source:Q.source.key,target:Q.target.key,sourceAttributes:Q.source.attributes,targetAttributes:Q.target.attributes,undirected:Q.undirected};return Q=Q.next,{done:!1,value:q}}};let Z=!1;return{[Symbol.iterator](){return this},next(){if(Z===!0)return{done:!0};return Z=!0,{done:!1,value:{edge:Q.key,attributes:Q.attributes,source:Q.source.key,target:Q.target.key,sourceAttributes:Q.source.attributes,targetAttributes:Q.target.attributes,undirected:Q.undirected}}}}}function P7(J,K){if(J.size===0)return[];if(K==="mixed"||K===J.type)return Array.from(J._edges.keys());let Q=K==="undirected"?J.undirectedSize:J.directedSize,Z=Array(Q),q=K==="undirected",W=J._edges.values(),V=0,z,B;while(z=W.next(),z.done!==!0)if(B=z.value,B.undirected===q)Z[V++]=B.key;return Z}function TQ(J,K,Q,Z){if(K.size===0)return;let q=Q!=="mixed"&&Q!==K.type,W=Q==="undirected",V,z,B=!1,H=K._edges.values();while(V=H.next(),V.done!==!0){if(z=V.value,q&&z.undirected!==W)continue;let{key:Y,attributes:$,source:X,target:j}=z;if(B=Z(Y,$,X.key,j.key,X.attributes,j.attributes,z.undirected),J&&B)return Y}return}function I7(J,K){if(J.size===0)return u0();let Q=K!=="mixed"&&K!==J.type,Z=K==="undirected",q=J._edges.values();return{[Symbol.iterator](){return this},next(){let W,V;while(!0){if(W=q.next(),W.done)return W;if(V=W.value,Q&&V.undirected!==Z)continue;break}return{value:{edge:V.key,attributes:V.attributes,source:V.source.key,target:V.target.key,sourceAttributes:V.source.attributes,targetAttributes:V.target.attributes,undirected:V.undirected},done:!1}}}}function SJ(J,K,Q,Z,q,W){let V=K?T7:L7,z;if(Q!=="undirected"){if(Z!=="out"){if(z=V(J,q.in,W),J&&z)return z}if(Z!=="in"){if(z=V(J,q.out,W,!Z?q.key:void 0),J&&z)return z}}if(Q!=="directed"){if(z=V(J,q.undirected,W),J&&z)return z}return}function k7(J,K,Q,Z){let q=[];return SJ(!1,J,K,Q,Z,function(W){q.push(W)}),q}function S7(J,K,Q){let Z=u0();if(J!=="undirected"){if(K!=="out"&&typeof Q.in<"u")Z=A0(Z,IJ(Q.in));if(K!=="in"&&typeof Q.out<"u")Z=A0(Z,IJ(Q.out,!K?Q.key:void 0))}if(J!=="directed"&&typeof Q.undirected<"u")Z=A0(Z,IJ(Q.undirected));return Z}function RJ(J,K,Q,Z,q,W,V){let z=Q?C7:A7,B;if(K!=="undirected"){if(typeof q.in<"u"&&Z!=="out"){if(B=z(J,q.in,W,V),J&&B)return B}if(typeof q.out<"u"&&Z!=="in"&&(Z||q.key!==W)){if(B=z(J,q.out,W,V),J&&B)return B}}if(K!=="directed"){if(typeof q.undirected<"u"){if(B=z(J,q.undirected,W,V),J&&B)return B}}return}function R7(J,K,Q,Z,q){let W=[];return RJ(!1,J,K,Q,Z,q,function(V){W.push(V)}),W}function v7(J,K,Q,Z){let q=u0();if(J!=="undirected"){if(typeof Q.in<"u"&&K!=="out"&&Z in Q.in)q=A0(q,kJ(Q.in,Z));if(typeof Q.out<"u"&&K!=="in"&&Z in Q.out&&(K||Q.key!==Z))q=A0(q,kJ(Q.out,Z))}if(J!=="directed"){if(typeof Q.undirected<"u"&&Z in Q.undirected)q=A0(q,kJ(Q.undirected,Z))}return q}function G7(J,K){let{name:Q,type:Z,direction:q}=K;J.prototype[Q]=function(W,V){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return[];if(!arguments.length)return P7(this,Z);if(arguments.length===1){W=""+W;let z=this._nodes.get(W);if(typeof z>"u")throw new C(`Graph.${Q}: could not find the "${W}" node in the graph.`);return k7(this.multi,Z==="mixed"?this.type:Z,q,z)}if(arguments.length===2){W=""+W,V=""+V;let z=this._nodes.get(W);if(!z)throw new C(`Graph.${Q}: could not find the "${W}" source node in the graph.`);if(!this._nodes.has(V))throw new C(`Graph.${Q}: could not find the "${V}" target node in the graph.`);return R7(Z,this.multi,q,z,V)}throw new S(`Graph.${Q}: too many arguments (expecting 0, 1 or 2 and got ${arguments.length}).`)}}function O7(J,K){let{name:Q,type:Z,direction:q}=K,W="forEach"+Q[0].toUpperCase()+Q.slice(1,-1);J.prototype[W]=function(H,Y,$){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return;if(arguments.length===1)return $=H,TQ(!1,this,Z,$);if(arguments.length===2){H=""+H,$=Y;let X=this._nodes.get(H);if(typeof X>"u")throw new C(`Graph.${W}: could not find the "${H}" node in the graph.`);return SJ(!1,this.multi,Z==="mixed"?this.type:Z,q,X,$)}if(arguments.length===3){H=""+H,Y=""+Y;let X=this._nodes.get(H);if(!X)throw new C(`Graph.${W}: could not find the "${H}" source node in the graph.`);if(!this._nodes.has(Y))throw new C(`Graph.${W}: could not find the "${Y}" target node in the graph.`);return RJ(!1,Z,this.multi,q,X,Y,$)}throw new S(`Graph.${W}: too many arguments (expecting 1, 2 or 3 and got ${arguments.length}).`)};let V="map"+Q[0].toUpperCase()+Q.slice(1);J.prototype[V]=function(){let H=Array.prototype.slice.call(arguments),Y=H.pop(),$;if(H.length===0){let X=0;if(Z!=="directed")X+=this.undirectedSize;if(Z!=="undirected")X+=this.directedSize;$=Array(X);let j=0;H.push((w,L,A,G,k,T,P)=>{$[j++]=Y(w,L,A,G,k,T,P)})}else $=[],H.push((X,j,w,L,A,G,k)=>{$.push(Y(X,j,w,L,A,G,k))});return this[W].apply(this,H),$};let z="filter"+Q[0].toUpperCase()+Q.slice(1);J.prototype[z]=function(){let H=Array.prototype.slice.call(arguments),Y=H.pop(),$=[];return H.push((X,j,w,L,A,G,k)=>{if(Y(X,j,w,L,A,G,k))$.push(X)}),this[W].apply(this,H),$};let B="reduce"+Q[0].toUpperCase()+Q.slice(1);J.prototype[B]=function(){let H=Array.prototype.slice.call(arguments);if(H.length<2||H.length>4)throw new S(`Graph.${B}: invalid number of arguments (expecting 2, 3 or 4 and got ${H.length}).`);if(typeof H[H.length-1]==="function"&&typeof H[H.length-2]!=="function")throw new S(`Graph.${B}: missing initial value. You must provide it because the callback takes more than one argument and we cannot infer the initial value from the first iteration, as you could with a simple array.`);let Y,$;if(H.length===2)Y=H[0],$=H[1],H=[];else if(H.length===3)Y=H[1],$=H[2],H=[H[0]];else if(H.length===4)Y=H[2],$=H[3],H=[H[0],H[1]];let X=$;return H.push((j,w,L,A,G,k,T)=>{X=Y(X,j,w,L,A,G,k,T)}),this[W].apply(this,H),X}}function f7(J,K){let{name:Q,type:Z,direction:q}=K,W="find"+Q[0].toUpperCase()+Q.slice(1,-1);J.prototype[W]=function(B,H,Y){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return!1;if(arguments.length===1)return Y=B,TQ(!0,this,Z,Y);if(arguments.length===2){B=""+B,Y=H;let $=this._nodes.get(B);if(typeof $>"u")throw new C(`Graph.${W}: could not find the "${B}" node in the graph.`);return SJ(!0,this.multi,Z==="mixed"?this.type:Z,q,$,Y)}if(arguments.length===3){B=""+B,H=""+H;let $=this._nodes.get(B);if(!$)throw new C(`Graph.${W}: could not find the "${B}" source node in the graph.`);if(!this._nodes.has(H))throw new C(`Graph.${W}: could not find the "${H}" target node in the graph.`);return RJ(!0,Z,this.multi,q,$,H,Y)}throw new S(`Graph.${W}: too many arguments (expecting 1, 2 or 3 and got ${arguments.length}).`)};let V="some"+Q[0].toUpperCase()+Q.slice(1,-1);J.prototype[V]=function(){let B=Array.prototype.slice.call(arguments),H=B.pop();if(B.push(($,X,j,w,L,A,G)=>{return H($,X,j,w,L,A,G)}),this[W].apply(this,B))return!0;return!1};let z="every"+Q[0].toUpperCase()+Q.slice(1,-1);J.prototype[z]=function(){let B=Array.prototype.slice.call(arguments),H=B.pop();if(B.push(($,X,j,w,L,A,G)=>{return!H($,X,j,w,L,A,G)}),this[W].apply(this,B))return!1;return!0}}function M7(J,K){let{name:Q,type:Z,direction:q}=K,W=Q.slice(0,-1)+"Entries";J.prototype[W]=function(V,z){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return u0();if(!arguments.length)return I7(this,Z);if(arguments.length===1){V=""+V;let B=this._nodes.get(V);if(!B)throw new C(`Graph.${W}: could not find the "${V}" node in the graph.`);return S7(Z,q,B)}if(arguments.length===2){V=""+V,z=""+z;let B=this._nodes.get(V);if(!B)throw new C(`Graph.${W}: could not find the "${V}" source node in the graph.`);if(!this._nodes.has(z))throw new C(`Graph.${W}: could not find the "${z}" target node in the graph.`);return v7(Z,q,B,z)}throw new S(`Graph.${W}: too many arguments (expecting 0, 1 or 2 and got ${arguments.length}).`)}}function b7(J){F7.forEach((K)=>{G7(J,K),O7(J,K),f7(J,K),M7(J,K)})}var N7=[{name:"neighbors",type:"mixed"},{name:"inNeighbors",type:"directed",direction:"in"},{name:"outNeighbors",type:"directed",direction:"out"},{name:"inboundNeighbors",type:"mixed",direction:"in"},{name:"outboundNeighbors",type:"mixed",direction:"out"},{name:"directedNeighbors",type:"directed"},{name:"undirectedNeighbors",type:"undirected"}];function KJ(){this.A=null,this.B=null}KJ.prototype.wrap=function(J){if(this.A===null)this.A=J;else if(this.B===null)this.B=J};KJ.prototype.has=function(J){if(this.A!==null&&J in this.A)return!0;if(this.B!==null&&J in this.B)return!0;return!1};function n0(J,K,Q,Z,q){for(let W in Z){let V=Z[W],z=V.source,B=V.target,H=z===Q?B:z;if(K&&K.has(H.key))continue;let Y=q(H.key,H.attributes);if(J&&Y)return H.key}return}function vJ(J,K,Q,Z,q){if(K!=="mixed"){if(K==="undirected")return n0(J,null,Z,Z.undirected,q);if(typeof Q==="string")return n0(J,null,Z,Z[Q],q)}let W=new KJ,V;if(K!=="undirected"){if(Q!=="out"){if(V=n0(J,null,Z,Z.in,q),J&&V)return V;W.wrap(Z.in)}if(Q!=="in"){if(V=n0(J,W,Z,Z.out,q),J&&V)return V;W.wrap(Z.out)}}if(K!=="directed"){if(V=n0(J,W,Z,Z.undirected,q),J&&V)return V}return}function y7(J,K,Q){if(J!=="mixed"){if(J==="undirected")return Object.keys(Q.undirected);if(typeof K==="string")return Object.keys(Q[K])}let Z=[];return vJ(!1,J,K,Q,function(q){Z.push(q)}),Z}function d0(J,K,Q){let Z=Object.keys(Q),q=Z.length,W=0;return{[Symbol.iterator](){return this},next(){let V=null;do{if(W>=q){if(J)J.wrap(Q);return{done:!0}}let z=Q[Z[W++]],B=z.source,H=z.target;if(V=B===K?H:B,J&&J.has(V.key)){V=null;continue}}while(V===null);return{done:!1,value:{neighbor:V.key,attributes:V.attributes}}}}}function x7(J,K,Q){if(J!=="mixed"){if(J==="undirected")return d0(null,Q,Q.undirected);if(typeof K==="string")return d0(null,Q,Q[K])}let Z=u0(),q=new KJ;if(J!=="undirected"){if(K!=="out")Z=A0(Z,d0(q,Q,Q.in));if(K!=="in")Z=A0(Z,d0(q,Q,Q.out))}if(J!=="directed")Z=A0(Z,d0(q,Q,Q.undirected));return Z}function E7(J,K){let{name:Q,type:Z,direction:q}=K;J.prototype[Q]=function(W){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return[];W=""+W;let V=this._nodes.get(W);if(typeof V>"u")throw new C(`Graph.${Q}: could not find the "${W}" node in the graph.`);return y7(Z==="mixed"?this.type:Z,q,V)}}function D7(J,K){let{name:Q,type:Z,direction:q}=K,W="forEach"+Q[0].toUpperCase()+Q.slice(1,-1);J.prototype[W]=function(H,Y){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return;H=""+H;let $=this._nodes.get(H);if(typeof $>"u")throw new C(`Graph.${W}: could not find the "${H}" node in the graph.`);vJ(!1,Z==="mixed"?this.type:Z,q,$,Y)};let V="map"+Q[0].toUpperCase()+Q.slice(1);J.prototype[V]=function(H,Y){let $=[];return this[W](H,(X,j)=>{$.push(Y(X,j))}),$};let z="filter"+Q[0].toUpperCase()+Q.slice(1);J.prototype[z]=function(H,Y){let $=[];return this[W](H,(X,j)=>{if(Y(X,j))$.push(X)}),$};let B="reduce"+Q[0].toUpperCase()+Q.slice(1);J.prototype[B]=function(H,Y,$){if(arguments.length<3)throw new S(`Graph.${B}: missing initial value. You must provide it because the callback takes more than one argument and we cannot infer the initial value from the first iteration, as you could with a simple array.`);let X=$;return this[W](H,(j,w)=>{X=Y(X,j,w)}),X}}function _7(J,K){let{name:Q,type:Z,direction:q}=K,W=Q[0].toUpperCase()+Q.slice(1,-1),V="find"+W;J.prototype[V]=function(H,Y){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return;H=""+H;let $=this._nodes.get(H);if(typeof $>"u")throw new C(`Graph.${V}: could not find the "${H}" node in the graph.`);return vJ(!0,Z==="mixed"?this.type:Z,q,$,Y)};let z="some"+W;J.prototype[z]=function(H,Y){if(this[V](H,Y))return!0;return!1};let B="every"+W;J.prototype[B]=function(H,Y){if(this[V](H,(X,j)=>{return!Y(X,j)}))return!1;return!0}}function u7(J,K){let{name:Q,type:Z,direction:q}=K,W=Q.slice(0,-1)+"Entries";J.prototype[W]=function(V){if(Z!=="mixed"&&this.type!=="mixed"&&Z!==this.type)return u0();V=""+V;let z=this._nodes.get(V);if(typeof z>"u")throw new C(`Graph.${W}: could not find the "${V}" node in the graph.`);return x7(Z==="mixed"?this.type:Z,q,z)}}function h7(J){N7.forEach((K)=>{E7(J,K),D7(J,K),_7(J,K),u7(J,K)})}function QJ(J,K,Q,Z,q){let W=Z._nodes.values(),V=Z.type,z,B,H,Y,$,X,j;while(z=W.next(),z.done!==!0){let w=!1;if(B=z.value,V!=="undirected"){Y=B.out;for(H in Y){$=Y[H];do{if(X=$.target,w=!0,j=q(B.key,X.key,B.attributes,X.attributes,$.key,$.attributes,$.undirected),J&&j)return $;$=$.next}while($)}}if(V!=="directed"){Y=B.undirected;for(H in Y){if(K&&B.key>H)continue;$=Y[H];do{if(X=$.target,X.key!==H)X=$.source;if(w=!0,j=q(B.key,X.key,B.attributes,X.attributes,$.key,$.attributes,$.undirected),J&&j)return $;$=$.next}while($)}}if(Q&&!w){if(j=q(B.key,null,B.attributes,null,null,null,null),J&&j)return null}}return}function m7(J,K){let Q={key:J};if(!YQ(K.attributes))Q.attributes=p({},K.attributes);return Q}function c7(J,K,Q){let Z={key:K,source:Q.source.key,target:Q.target.key};if(!YQ(Q.attributes))Z.attributes=p({},Q.attributes);if(J==="mixed"&&Q.undirected)Z.undirected=!0;return Z}function l7(J){if(!i(J))throw new S('Graph.import: invalid serialized node. A serialized node should be a plain object with at least a "key" property.');if(!("key"in J))throw new S("Graph.import: serialized node is missing its key.");if("attributes"in J&&(!i(J.attributes)||J.attributes===null))throw new S("Graph.import: invalid attributes. Attributes should be a plain object, null or omitted.")}function p7(J){if(!i(J))throw new S('Graph.import: invalid serialized edge. A serialized edge should be a plain object with at least a "source" & "target" property.');if(!("source"in J))throw new S("Graph.import: serialized edge is missing its source.");if(!("target"in J))throw new S("Graph.import: serialized edge is missing its target.");if("attributes"in J&&(!i(J.attributes)||J.attributes===null))throw new S("Graph.import: invalid attributes. Attributes should be a plain object, null or omitted.");if("undirected"in J&&typeof J.undirected!=="boolean")throw new S("Graph.import: invalid undirectedness information. Undirected should be boolean or omitted.")}var g7=n6(),s7=new Set(["directed","undirected","mixed"]),HQ=new Set(["domain","_events","_eventsCount","_maxListeners"]),n7=[{name:(J)=>`${J}Edge`,generateKey:!0},{name:(J)=>`${J}DirectedEdge`,generateKey:!0,type:"directed"},{name:(J)=>`${J}UndirectedEdge`,generateKey:!0,type:"undirected"},{name:(J)=>`${J}EdgeWithKey`},{name:(J)=>`${J}DirectedEdgeWithKey`,type:"directed"},{name:(J)=>`${J}UndirectedEdgeWithKey`,type:"undirected"}],d7={allowSelfLoops:!0,multi:!1,type:"mixed"};function i7(J,K,Q){if(Q&&!i(Q))throw new S(`Graph.addNode: invalid attributes. Expecting an object but got "${Q}"`);if(K=""+K,Q=Q||{},J._nodes.has(K))throw new f(`Graph.addNode: the "${K}" node already exist in the graph.`);let Z=new J.NodeDataClass(K,Q);return J._nodes.set(K,Z),J.emit("nodeAdded",{key:K,attributes:Q}),Z}function $Q(J,K,Q){let Z=new J.NodeDataClass(K,Q);return J._nodes.set(K,Z),J.emit("nodeAdded",{key:K,attributes:Q}),Z}function AQ(J,K,Q,Z,q,W,V,z){if(!Z&&J.type==="undirected")throw new f(`Graph.${K}: you cannot add a directed edge to an undirected graph. Use the #.addEdge or #.addUndirectedEdge instead.`);if(Z&&J.type==="directed")throw new f(`Graph.${K}: you cannot add an undirected edge to a directed graph. Use the #.addEdge or #.addDirectedEdge instead.`);if(z&&!i(z))throw new S(`Graph.${K}: invalid attributes. Expecting an object but got "${z}"`);if(W=""+W,V=""+V,z=z||{},!J.allowSelfLoops&&W===V)throw new f(`Graph.${K}: source & target are the same ("${W}"), thus creating a loop explicitly forbidden by this graph 'allowSelfLoops' option set to false.`);let B=J._nodes.get(W),H=J._nodes.get(V);if(!B)throw new C(`Graph.${K}: source node "${W}" not found.`);if(!H)throw new C(`Graph.${K}: target node "${V}" not found.`);let Y={key:null,undirected:Z,source:W,target:V,attributes:z};if(Q)q=J._edgeKeyGenerator();else if(q=""+q,J._edges.has(q))throw new f(`Graph.${K}: the "${q}" edge already exists in the graph.`);if(!J.multi&&(Z?typeof B.undirected[V]<"u":typeof B.out[V]<"u"))throw new f(`Graph.${K}: an edge linking "${W}" to "${V}" already exists. If you really want to add multiple edges linking those nodes, you should create a multi graph by using the 'multi' option.`);let $=new h0(Z,q,B,H,z);J._edges.set(q,$);let X=W===V;if(Z){if(B.undirectedDegree++,H.undirectedDegree++,X)B.undirectedLoops++,J._undirectedSelfLoopCount++}else if(B.outDegree++,H.inDegree++,X)B.directedLoops++,J._directedSelfLoopCount++;if(J.multi)$.attachMulti();else $.attach();if(Z)J._undirectedSize++;else J._directedSize++;return Y.key=q,J.emit("edgeAdded",Y),q}function o7(J,K,Q,Z,q,W,V,z,B){if(!Z&&J.type==="undirected")throw new f(`Graph.${K}: you cannot merge/update a directed edge to an undirected graph. Use the #.mergeEdge/#.updateEdge or #.addUndirectedEdge instead.`);if(Z&&J.type==="directed")throw new f(`Graph.${K}: you cannot merge/update an undirected edge to a directed graph. Use the #.mergeEdge/#.updateEdge or #.addDirectedEdge instead.`);if(z){if(B){if(typeof z!=="function")throw new S(`Graph.${K}: invalid updater function. Expecting a function but got "${z}"`)}else if(!i(z))throw new S(`Graph.${K}: invalid attributes. Expecting an object but got "${z}"`)}W=""+W,V=""+V;let H;if(B)H=z,z=void 0;if(!J.allowSelfLoops&&W===V)throw new f(`Graph.${K}: source & target are the same ("${W}"), thus creating a loop explicitly forbidden by this graph 'allowSelfLoops' option set to false.`);let Y=J._nodes.get(W),$=J._nodes.get(V),X,j;if(!Q){if(X=J._edges.get(q),X){if(X.source.key!==W||X.target.key!==V){if(!Z||X.source.key!==V||X.target.key!==W)throw new f(`Graph.${K}: inconsistency detected when attempting to merge the "${q}" edge with "${W}" source & "${V}" target vs. ("${X.source.key}", "${X.target.key}").`)}j=X}}if(!j&&!J.multi&&Y)j=Z?Y.undirected[V]:Y.out[V];if(j){let k=[j.key,!1,!1,!1];if(B?!H:!z)return k;if(B){let T=j.attributes;j.attributes=H(T),J.emit("edgeAttributesUpdated",{type:"replace",key:j.key,attributes:j.attributes})}else p(j.attributes,z),J.emit("edgeAttributesUpdated",{type:"merge",key:j.key,attributes:j.attributes,data:z});return k}if(z=z||{},B&&H)z=H(z);let w={key:null,undirected:Z,source:W,target:V,attributes:z};if(Q)q=J._edgeKeyGenerator();else if(q=""+q,J._edges.has(q))throw new f(`Graph.${K}: the "${q}" edge already exists in the graph.`);let L=!1,A=!1;if(!Y){if(Y=$Q(J,W,{}),L=!0,W===V)$=Y,A=!0}if(!$)$=$Q(J,V,{}),A=!0;X=new h0(Z,q,Y,$,z),J._edges.set(q,X);let G=W===V;if(Z){if(Y.undirectedDegree++,$.undirectedDegree++,G)Y.undirectedLoops++,J._undirectedSelfLoopCount++}else if(Y.outDegree++,$.inDegree++,G)Y.directedLoops++,J._directedSelfLoopCount++;if(J.multi)X.attachMulti();else X.attach();if(Z)J._undirectedSize++;else J._directedSize++;return w.key=q,J.emit("edgeAdded",w),[q,!0,L,A]}function _0(J,K){J._edges.delete(K.key);let{source:Q,target:Z,attributes:q}=K,W=K.undirected,V=Q===Z;if(W){if(Q.undirectedDegree--,Z.undirectedDegree--,V)Q.undirectedLoops--,J._undirectedSelfLoopCount--}else if(Q.outDegree--,Z.inDegree--,V)Q.directedLoops--,J._directedSelfLoopCount--;if(J.multi)K.detachMulti();else K.detach();if(W)J._undirectedSize--;else J._directedSize--;J.emit("edgeDropped",{key:K.key,attributes:q,source:Q.key,target:Z.key,undirected:W})}class x extends w0{constructor(J){super();if(J=p({},d7,J),typeof J.multi!=="boolean")throw new S(`Graph.constructor: invalid 'multi' option. Expecting a boolean but got "${J.multi}".`);if(!s7.has(J.type))throw new S(`Graph.constructor: invalid 'type' option. Should be one of "mixed", "directed" or "undirected" but got "${J.type}".`);if(typeof J.allowSelfLoops!=="boolean")throw new S(`Graph.constructor: invalid 'allowSelfLoops' option. Expecting a boolean but got "${J.allowSelfLoops}".`);let K=J.type==="mixed"?XQ:J.type==="directed"?jQ:UQ;V0(this,"NodeDataClass",K);let Q="geid_"+g7()+"_",Z=0,q=()=>{let W;do W=Q+Z++;while(this._edges.has(W));return W};V0(this,"_attributes",{}),V0(this,"_nodes",new Map),V0(this,"_edges",new Map),V0(this,"_directedSize",0),V0(this,"_undirectedSize",0),V0(this,"_directedSelfLoopCount",0),V0(this,"_undirectedSelfLoopCount",0),V0(this,"_edgeKeyGenerator",q),V0(this,"_options",J),HQ.forEach((W)=>V0(this,W,this[W])),X0(this,"order",()=>this._nodes.size),X0(this,"size",()=>this._edges.size),X0(this,"directedSize",()=>this._directedSize),X0(this,"undirectedSize",()=>this._undirectedSize),X0(this,"selfLoopCount",()=>this._directedSelfLoopCount+this._undirectedSelfLoopCount),X0(this,"directedSelfLoopCount",()=>this._directedSelfLoopCount),X0(this,"undirectedSelfLoopCount",()=>this._undirectedSelfLoopCount),X0(this,"multi",this._options.multi),X0(this,"type",this._options.type),X0(this,"allowSelfLoops",this._options.allowSelfLoops),X0(this,"implementation",()=>"graphology")}_resetInstanceCounters(){this._directedSize=0,this._undirectedSize=0,this._directedSelfLoopCount=0,this._undirectedSelfLoopCount=0}hasNode(J){return this._nodes.has(""+J)}hasDirectedEdge(J,K){if(this.type==="undirected")return!1;if(arguments.length===1){let Q=""+J,Z=this._edges.get(Q);return!!Z&&!Z.undirected}else if(arguments.length===2){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)return!1;return Q.out.hasOwnProperty(K)}throw new S(`Graph.hasDirectedEdge: invalid arity (${arguments.length}, instead of 1 or 2). You can either ask for an edge id or for the existence of an edge between a source & a target.`)}hasUndirectedEdge(J,K){if(this.type==="directed")return!1;if(arguments.length===1){let Q=""+J,Z=this._edges.get(Q);return!!Z&&Z.undirected}else if(arguments.length===2){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)return!1;return Q.undirected.hasOwnProperty(K)}throw new S(`Graph.hasDirectedEdge: invalid arity (${arguments.length}, instead of 1 or 2). You can either ask for an edge id or for the existence of an edge between a source & a target.`)}hasEdge(J,K){if(arguments.length===1){let Q=""+J;return this._edges.has(Q)}else if(arguments.length===2){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)return!1;return typeof Q.out<"u"&&Q.out.hasOwnProperty(K)||typeof Q.undirected<"u"&&Q.undirected.hasOwnProperty(K)}throw new S(`Graph.hasEdge: invalid arity (${arguments.length}, instead of 1 or 2). You can either ask for an edge id or for the existence of an edge between a source & a target.`)}directedEdge(J,K){if(this.type==="undirected")return;if(J=""+J,K=""+K,this.multi)throw new f("Graph.directedEdge: this method is irrelevant with multigraphs since there might be multiple edges between source & target. See #.directedEdges instead.");let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.directedEdge: could not find the "${J}" source node in the graph.`);if(!this._nodes.has(K))throw new C(`Graph.directedEdge: could not find the "${K}" target node in the graph.`);let Z=Q.out&&Q.out[K]||void 0;if(Z)return Z.key}undirectedEdge(J,K){if(this.type==="directed")return;if(J=""+J,K=""+K,this.multi)throw new f("Graph.undirectedEdge: this method is irrelevant with multigraphs since there might be multiple edges between source & target. See #.undirectedEdges instead.");let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.undirectedEdge: could not find the "${J}" source node in the graph.`);if(!this._nodes.has(K))throw new C(`Graph.undirectedEdge: could not find the "${K}" target node in the graph.`);let Z=Q.undirected&&Q.undirected[K]||void 0;if(Z)return Z.key}edge(J,K){if(this.multi)throw new f("Graph.edge: this method is irrelevant with multigraphs since there might be multiple edges between source & target. See #.edges instead.");J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.edge: could not find the "${J}" source node in the graph.`);if(!this._nodes.has(K))throw new C(`Graph.edge: could not find the "${K}" target node in the graph.`);let Z=Q.out&&Q.out[K]||Q.undirected&&Q.undirected[K]||void 0;if(Z)return Z.key}areDirectedNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areDirectedNeighbors: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return!1;return K in Q.in||K in Q.out}areOutNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areOutNeighbors: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return!1;return K in Q.out}areInNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areInNeighbors: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return!1;return K in Q.in}areUndirectedNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areUndirectedNeighbors: could not find the "${J}" node in the graph.`);if(this.type==="directed")return!1;return K in Q.undirected}areNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areNeighbors: could not find the "${J}" node in the graph.`);if(this.type!=="undirected"){if(K in Q.in||K in Q.out)return!0}if(this.type!=="directed"){if(K in Q.undirected)return!0}return!1}areInboundNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areInboundNeighbors: could not find the "${J}" node in the graph.`);if(this.type!=="undirected"){if(K in Q.in)return!0}if(this.type!=="directed"){if(K in Q.undirected)return!0}return!1}areOutboundNeighbors(J,K){J=""+J,K=""+K;let Q=this._nodes.get(J);if(!Q)throw new C(`Graph.areOutboundNeighbors: could not find the "${J}" node in the graph.`);if(this.type!=="undirected"){if(K in Q.out)return!0}if(this.type!=="directed"){if(K in Q.undirected)return!0}return!1}inDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.inDegree: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.inDegree}outDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.outDegree: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.outDegree}directedDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.directedDegree: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.inDegree+K.outDegree}undirectedDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.undirectedDegree: could not find the "${J}" node in the graph.`);if(this.type==="directed")return 0;return K.undirectedDegree}inboundDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.inboundDegree: could not find the "${J}" node in the graph.`);let Q=0;if(this.type!=="directed")Q+=K.undirectedDegree;if(this.type!=="undirected")Q+=K.inDegree;return Q}outboundDegree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.outboundDegree: could not find the "${J}" node in the graph.`);let Q=0;if(this.type!=="directed")Q+=K.undirectedDegree;if(this.type!=="undirected")Q+=K.outDegree;return Q}degree(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.degree: could not find the "${J}" node in the graph.`);let Q=0;if(this.type!=="directed")Q+=K.undirectedDegree;if(this.type!=="undirected")Q+=K.inDegree+K.outDegree;return Q}inDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.inDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.inDegree-K.directedLoops}outDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.outDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.outDegree-K.directedLoops}directedDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.directedDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);if(this.type==="undirected")return 0;return K.inDegree+K.outDegree-K.directedLoops*2}undirectedDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.undirectedDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);if(this.type==="directed")return 0;return K.undirectedDegree-K.undirectedLoops*2}inboundDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.inboundDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);let Q=0,Z=0;if(this.type!=="directed")Q+=K.undirectedDegree,Z+=K.undirectedLoops*2;if(this.type!=="undirected")Q+=K.inDegree,Z+=K.directedLoops;return Q-Z}outboundDegreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.outboundDegreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);let Q=0,Z=0;if(this.type!=="directed")Q+=K.undirectedDegree,Z+=K.undirectedLoops*2;if(this.type!=="undirected")Q+=K.outDegree,Z+=K.directedLoops;return Q-Z}degreeWithoutSelfLoops(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.degreeWithoutSelfLoops: could not find the "${J}" node in the graph.`);let Q=0,Z=0;if(this.type!=="directed")Q+=K.undirectedDegree,Z+=K.undirectedLoops*2;if(this.type!=="undirected")Q+=K.inDegree+K.outDegree,Z+=K.directedLoops*2;return Q-Z}source(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.source: could not find the "${J}" edge in the graph.`);return K.source.key}target(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.target: could not find the "${J}" edge in the graph.`);return K.target.key}extremities(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.extremities: could not find the "${J}" edge in the graph.`);return[K.source.key,K.target.key]}opposite(J,K){J=""+J,K=""+K;let Q=this._edges.get(K);if(!Q)throw new C(`Graph.opposite: could not find the "${K}" edge in the graph.`);let Z=Q.source.key,q=Q.target.key;if(J===Z)return q;if(J===q)return Z;throw new C(`Graph.opposite: the "${J}" node is not attached to the "${K}" edge (${Z}, ${q}).`)}hasExtremity(J,K){J=""+J,K=""+K;let Q=this._edges.get(J);if(!Q)throw new C(`Graph.hasExtremity: could not find the "${J}" edge in the graph.`);return Q.source.key===K||Q.target.key===K}isUndirected(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.isUndirected: could not find the "${J}" edge in the graph.`);return K.undirected}isDirected(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.isDirected: could not find the "${J}" edge in the graph.`);return!K.undirected}isSelfLoop(J){J=""+J;let K=this._edges.get(J);if(!K)throw new C(`Graph.isSelfLoop: could not find the "${J}" edge in the graph.`);return K.source===K.target}addNode(J,K){return i7(this,J,K).key}mergeNode(J,K){if(K&&!i(K))throw new S(`Graph.mergeNode: invalid attributes. Expecting an object but got "${K}"`);J=""+J,K=K||{};let Q=this._nodes.get(J);if(Q){if(K)p(Q.attributes,K),this.emit("nodeAttributesUpdated",{type:"merge",key:J,attributes:Q.attributes,data:K});return[J,!1]}return Q=new this.NodeDataClass(J,K),this._nodes.set(J,Q),this.emit("nodeAdded",{key:J,attributes:K}),[J,!0]}updateNode(J,K){if(K&&typeof K!=="function")throw new S(`Graph.updateNode: invalid updater function. Expecting a function but got "${K}"`);J=""+J;let Q=this._nodes.get(J);if(Q){if(K){let q=Q.attributes;Q.attributes=K(q),this.emit("nodeAttributesUpdated",{type:"replace",key:J,attributes:Q.attributes})}return[J,!1]}let Z=K?K({}):{};return Q=new this.NodeDataClass(J,Z),this._nodes.set(J,Q),this.emit("nodeAdded",{key:J,attributes:Z}),[J,!0]}dropNode(J){J=""+J;let K=this._nodes.get(J);if(!K)throw new C(`Graph.dropNode: could not find the "${J}" node in the graph.`);let Q;if(this.type!=="undirected"){for(let Z in K.out){Q=K.out[Z];do _0(this,Q),Q=Q.next;while(Q)}for(let Z in K.in){Q=K.in[Z];do _0(this,Q),Q=Q.next;while(Q)}}if(this.type!=="directed")for(let Z in K.undirected){Q=K.undirected[Z];do _0(this,Q),Q=Q.next;while(Q)}this._nodes.delete(J),this.emit("nodeDropped",{key:J,attributes:K.attributes})}dropEdge(J){let K;if(arguments.length>1){let Q=""+arguments[0],Z=""+arguments[1];if(K=z0(this,Q,Z,this.type),!K)throw new C(`Graph.dropEdge: could not find the "${Q}" -> "${Z}" edge in the graph.`)}else if(J=""+J,K=this._edges.get(J),!K)throw new C(`Graph.dropEdge: could not find the "${J}" edge in the graph.`);return _0(this,K),this}dropDirectedEdge(J,K){if(arguments.length<2)throw new f("Graph.dropDirectedEdge: it does not make sense to try and drop a directed edge by key. What if the edge with this key is undirected? Use #.dropEdge for this purpose instead.");if(this.multi)throw new f("Graph.dropDirectedEdge: cannot use a {source,target} combo when dropping an edge in a MultiGraph since we cannot infer the one you want to delete as there could be multiple ones.");J=""+J,K=""+K;let Q=z0(this,J,K,"directed");if(!Q)throw new C(`Graph.dropDirectedEdge: could not find a "${J}" -> "${K}" edge in the graph.`);return _0(this,Q),this}dropUndirectedEdge(J,K){if(arguments.length<2)throw new f("Graph.dropUndirectedEdge: it does not make sense to drop a directed edge by key. What if the edge with this key is undirected? Use #.dropEdge for this purpose instead.");if(this.multi)throw new f("Graph.dropUndirectedEdge: cannot use a {source,target} combo when dropping an edge in a MultiGraph since we cannot infer the one you want to delete as there could be multiple ones.");let Q=z0(this,J,K,"undirected");if(!Q)throw new C(`Graph.dropUndirectedEdge: could not find a "${J}" -> "${K}" edge in the graph.`);return _0(this,Q),this}clear(){this._edges.clear(),this._nodes.clear(),this._resetInstanceCounters(),this.emit("cleared")}clearEdges(){let J=this._nodes.values(),K;while(K=J.next(),K.done!==!0)K.value.clear();this._edges.clear(),this._resetInstanceCounters(),this.emit("edgesCleared")}getAttribute(J){return this._attributes[J]}getAttributes(){return this._attributes}hasAttribute(J){return this._attributes.hasOwnProperty(J)}setAttribute(J,K){return this._attributes[J]=K,this.emit("attributesUpdated",{type:"set",attributes:this._attributes,name:J}),this}updateAttribute(J,K){if(typeof K!=="function")throw new S("Graph.updateAttribute: updater should be a function.");let Q=this._attributes[J];return this._attributes[J]=K(Q),this.emit("attributesUpdated",{type:"set",attributes:this._attributes,name:J}),this}removeAttribute(J){return delete this._attributes[J],this.emit("attributesUpdated",{type:"remove",attributes:this._attributes,name:J}),this}replaceAttributes(J){if(!i(J))throw new S("Graph.replaceAttributes: provided attributes are not a plain object.");return this._attributes=J,this.emit("attributesUpdated",{type:"replace",attributes:this._attributes}),this}mergeAttributes(J){if(!i(J))throw new S("Graph.mergeAttributes: provided attributes are not a plain object.");return p(this._attributes,J),this.emit("attributesUpdated",{type:"merge",attributes:this._attributes,data:J}),this}updateAttributes(J){if(typeof J!=="function")throw new S("Graph.updateAttributes: provided updater is not a function.");return this._attributes=J(this._attributes),this.emit("attributesUpdated",{type:"update",attributes:this._attributes}),this}updateEachNodeAttributes(J,K){if(typeof J!=="function")throw new S("Graph.updateEachNodeAttributes: expecting an updater function.");if(K&&!BQ(K))throw new S("Graph.updateEachNodeAttributes: invalid hints. Expecting an object having the following shape: {attributes?: [string]}");let Q=this._nodes.values(),Z,q;while(Z=Q.next(),Z.done!==!0)q=Z.value,q.attributes=J(q.key,q.attributes);this.emit("eachNodeAttributesUpdated",{hints:K?K:null})}updateEachEdgeAttributes(J,K){if(typeof J!=="function")throw new S("Graph.updateEachEdgeAttributes: expecting an updater function.");if(K&&!BQ(K))throw new S("Graph.updateEachEdgeAttributes: invalid hints. Expecting an object having the following shape: {attributes?: [string]}");let Q=this._edges.values(),Z,q,W,V;while(Z=Q.next(),Z.done!==!0)q=Z.value,W=q.source,V=q.target,q.attributes=J(q.key,q.attributes,W.key,V.key,W.attributes,V.attributes,q.undirected);this.emit("eachEdgeAttributesUpdated",{hints:K?K:null})}forEachAdjacencyEntry(J){if(typeof J!=="function")throw new S("Graph.forEachAdjacencyEntry: expecting a callback.");QJ(!1,!1,!1,this,J)}forEachAdjacencyEntryWithOrphans(J){if(typeof J!=="function")throw new S("Graph.forEachAdjacencyEntryWithOrphans: expecting a callback.");QJ(!1,!1,!0,this,J)}forEachAssymetricAdjacencyEntry(J){if(typeof J!=="function")throw new S("Graph.forEachAssymetricAdjacencyEntry: expecting a callback.");QJ(!1,!0,!1,this,J)}forEachAssymetricAdjacencyEntryWithOrphans(J){if(typeof J!=="function")throw new S("Graph.forEachAssymetricAdjacencyEntryWithOrphans: expecting a callback.");QJ(!1,!0,!0,this,J)}nodes(){return Array.from(this._nodes.keys())}forEachNode(J){if(typeof J!=="function")throw new S("Graph.forEachNode: expecting a callback.");let K=this._nodes.values(),Q,Z;while(Q=K.next(),Q.done!==!0)Z=Q.value,J(Z.key,Z.attributes)}findNode(J){if(typeof J!=="function")throw new S("Graph.findNode: expecting a callback.");let K=this._nodes.values(),Q,Z;while(Q=K.next(),Q.done!==!0)if(Z=Q.value,J(Z.key,Z.attributes))return Z.key;return}mapNodes(J){if(typeof J!=="function")throw new S("Graph.mapNode: expecting a callback.");let K=this._nodes.values(),Q,Z,q=Array(this.order),W=0;while(Q=K.next(),Q.done!==!0)Z=Q.value,q[W++]=J(Z.key,Z.attributes);return q}someNode(J){if(typeof J!=="function")throw new S("Graph.someNode: expecting a callback.");let K=this._nodes.values(),Q,Z;while(Q=K.next(),Q.done!==!0)if(Z=Q.value,J(Z.key,Z.attributes))return!0;return!1}everyNode(J){if(typeof J!=="function")throw new S("Graph.everyNode: expecting a callback.");let K=this._nodes.values(),Q,Z;while(Q=K.next(),Q.done!==!0)if(Z=Q.value,!J(Z.key,Z.attributes))return!1;return!0}filterNodes(J){if(typeof J!=="function")throw new S("Graph.filterNodes: expecting a callback.");let K=this._nodes.values(),Q,Z,q=[];while(Q=K.next(),Q.done!==!0)if(Z=Q.value,J(Z.key,Z.attributes))q.push(Z.key);return q}reduceNodes(J,K){if(typeof J!=="function")throw new S("Graph.reduceNodes: expecting a callback.");if(arguments.length<2)throw new S("Graph.reduceNodes: missing initial value. You must provide it because the callback takes more than one argument and we cannot infer the initial value from the first iteration, as you could with a simple array.");let Q=K,Z=this._nodes.values(),q,W;while(q=Z.next(),q.done!==!0)W=q.value,Q=J(Q,W.key,W.attributes);return Q}nodeEntries(){let J=this._nodes.values();return{[Symbol.iterator](){return this},next(){let K=J.next();if(K.done)return K;let Q=K.value;return{value:{node:Q.key,attributes:Q.attributes},done:!1}}}}export(){let J=Array(this._nodes.size),K=0;this._nodes.forEach((Z,q)=>{J[K++]=m7(q,Z)});let Q=Array(this._edges.size);return K=0,this._edges.forEach((Z,q)=>{Q[K++]=c7(this.type,q,Z)}),{options:{type:this.type,multi:this.multi,allowSelfLoops:this.allowSelfLoops},attributes:this.getAttributes(),nodes:J,edges:Q}}import(J,K=!1){if(J instanceof x)return J.forEachNode((z,B)=>{if(K)this.mergeNode(z,B);else this.addNode(z,B)}),J.forEachEdge((z,B,H,Y,$,X,j)=>{if(K)if(j)this.mergeUndirectedEdgeWithKey(z,H,Y,B);else this.mergeDirectedEdgeWithKey(z,H,Y,B);else if(j)this.addUndirectedEdgeWithKey(z,H,Y,B);else this.addDirectedEdgeWithKey(z,H,Y,B)}),this;if(!i(J))throw new S("Graph.import: invalid argument. Expecting a serialized graph or, alternatively, a Graph instance.");if(J.attributes){if(!i(J.attributes))throw new S("Graph.import: invalid attributes. Expecting a plain object.");if(K)this.mergeAttributes(J.attributes);else this.replaceAttributes(J.attributes)}let Q,Z,q,W,V;if(J.nodes){if(q=J.nodes,!Array.isArray(q))throw new S("Graph.import: invalid nodes. Expecting an array.");for(Q=0,Z=q.length;Q{let q=p({},Q.attributes);Q=new K.NodeDataClass(Z,q),K._nodes.set(Z,Q)}),K}copy(J){if(J=J||{},typeof J.type==="string"&&J.type!==this.type&&J.type!=="mixed")throw new f(`Graph.copy: cannot create an incompatible copy from "${this.type}" type to "${J.type}" because this would mean losing information about the current graph.`);if(typeof J.multi==="boolean"&&J.multi!==this.multi&&J.multi!==!0)throw new f("Graph.copy: cannot create an incompatible copy by downgrading a multi graph to a simple one because this would mean losing information about the current graph.");if(typeof J.allowSelfLoops==="boolean"&&J.allowSelfLoops!==this.allowSelfLoops&&J.allowSelfLoops!==!0)throw new f("Graph.copy: cannot create an incompatible copy from a graph allowing self loops to one that does not because this would mean losing information about the current graph.");let K=this.emptyCopy(J),Q=this._edges.values(),Z,q;while(Z=Q.next(),Z.done!==!0)q=Z.value,AQ(K,"copy",!1,q.undirected,q.key,q.source.key,q.target.key,p({},q.attributes));return K}toJSON(){return this.export()}toString(){return"[object Graph]"}inspect(){let J={};this._nodes.forEach((q,W)=>{J[W]=q.attributes});let K={},Q={};this._edges.forEach((q,W)=>{let V=q.undirected?"--":"->",z="",B=q.source.key,H=q.target.key,Y;if(q.undirected&&B>H)Y=B,B=H,H=Y;let $=`(${B})${V}(${H})`;if(!W.startsWith("geid_"))z+=`[${W}]: `;else if(this.multi){if(typeof Q[$]>"u")Q[$]=0;else Q[$]++;z+=`${Q[$]}. `}z+=$,K[z]=q.attributes});let Z={};for(let q in this)if(this.hasOwnProperty(q)&&!HQ.has(q)&&typeof this[q]!=="function"&&typeof q!=="symbol")Z[q]=this[q];return Z.attributes=this._attributes,Z.nodes=J,Z.edges=K,V0(Z,"constructor",this.constructor),Z}}if(typeof Symbol<"u")x.prototype[Symbol.for("nodejs.util.inspect.custom")]=x.prototype.inspect;n7.forEach((J)=>{["add","merge","update"].forEach((K)=>{let Q=J.name(K),Z=K==="add"?AQ:o7;if(J.generateKey)x.prototype[Q]=function(q,W,V){return Z(this,Q,!0,(J.type||this.type)==="undirected",null,q,W,V,K==="update")};else x.prototype[Q]=function(q,W,V,z){return Z(this,Q,!1,(J.type||this.type)==="undirected",q,W,V,z,K==="update")}})});q7(x);w7(x);b7(x);h7(x);class GJ extends x{constructor(J){let K=p({type:"directed"},J);if("multi"in K&&K.multi!==!1)throw new S("DirectedGraph.from: inconsistent indication that the graph should be multi in given options!");if(K.type!=="directed")throw new S('DirectedGraph.from: inconsistent "'+K.type+'" type in given options!');super(K)}}class OJ extends x{constructor(J){let K=p({type:"undirected"},J);if("multi"in K&&K.multi!==!1)throw new S("UndirectedGraph.from: inconsistent indication that the graph should be multi in given options!");if(K.type!=="undirected")throw new S('UndirectedGraph.from: inconsistent "'+K.type+'" type in given options!');super(K)}}class fJ extends x{constructor(J){let K=p({multi:!0},J);if("multi"in K&&K.multi!==!0)throw new S("MultiGraph.from: inconsistent indication that the graph should be simple in given options!");super(K)}}class MJ extends x{constructor(J){let K=p({type:"directed",multi:!0},J);if("multi"in K&&K.multi!==!0)throw new S("MultiDirectedGraph.from: inconsistent indication that the graph should be simple in given options!");if(K.type!=="directed")throw new S('MultiDirectedGraph.from: inconsistent "'+K.type+'" type in given options!');super(K)}}class bJ extends x{constructor(J){let K=p({type:"undirected",multi:!0},J);if("multi"in K&&K.multi!==!0)throw new S("MultiUndirectedGraph.from: inconsistent indication that the graph should be simple in given options!");if(K.type!=="undirected")throw new S('MultiUndirectedGraph.from: inconsistent "'+K.type+'" type in given options!');super(K)}}function m0(J){J.from=function(K,Q){let Z=p({},K.options,Q),q=new J(Z);return q.import(K),q}}m0(x);m0(GJ);m0(OJ);m0(fJ);m0(MJ);m0(bJ);x.Graph=x;x.DirectedGraph=GJ;x.UndirectedGraph=OJ;x.MultiGraph=fJ;x.MultiDirectedGraph=MJ;x.MultiUndirectedGraph=bJ;x.InvalidArgumentsGraphError=S;x.NotFoundGraphError=C;x.UsageGraphError=f;var tJ=TJ(xQ(),1);function A9(J,K){if(typeof J!="object"||!J)return J;var Q=J[Symbol.toPrimitive];if(Q!==void 0){var Z=Q.call(J,K||"default");if(typeof Z!="object")return Z;throw TypeError("@@toPrimitive must return a primitive value.")}return(K==="string"?String:Number)(J)}function N0(J){var K=A9(J,"string");return typeof K=="symbol"?K:K+""}function g(J,K){if(!(J instanceof K))throw TypeError("Cannot call a class as a function")}function EQ(J,K){for(var Q=0;QJ.length)&&(K=J.length);for(var Q=0,Z=Array(K);Q>>16,Q=(J&65280)>>>8,Z=J&255,q=255,W=uQ(K,Q,Z,q,!0);return _J[J]=W,W}function mJ(J,K,Q,Z){return Q+(K<<8)+(J<<16)}function cJ(J,K,Q,Z,q,W){var V=Math.floor(Q/W*q),z=Math.floor(J.drawingBufferHeight/W-Z/W*q),B=new Uint8Array(4);J.bindFramebuffer(J.FRAMEBUFFER,K),J.readPixels(V,z,1,1,J.RGBA,J.UNSIGNED_BYTE,B);var H=v0(B,4),Y=H[0],$=H[1],X=H[2],j=H[3];return[Y,$,X,j]}function F(J,K,Q){return(K=N0(K))in J?Object.defineProperty(J,K,{value:Q,enumerable:!0,configurable:!0,writable:!0}):J[K]=Q,J}function hQ(J,K){var Q=Object.keys(J);if(Object.getOwnPropertySymbols){var Z=Object.getOwnPropertySymbols(J);K&&(Z=Z.filter(function(q){return Object.getOwnPropertyDescriptor(J,q).enumerable})),Q.push.apply(Q,Z)}return Q}function v(J){for(var K=1;KP){var M="…";H=H+M,O=J.measureText(H).width;while(O>P&&H.length>1)H=H.slice(0,-2)+M,O=J.measureText(H).width;if(H.length<4)return}var I;if(k>0)if(T>0)I=Math.acos(k/P);else I=Math.asin(T/P);else if(T>0)I=Math.acos(k/P)+Math.PI;else I=Math.asin(k/P)+Math.PI/2;J.save(),J.translate(A,G),J.rotate(I),J.fillText(H,-O/2,K.size/2+W),J.restore()}function nJ(J,K,Q){if(!K.label)return;var{labelSize:Z,labelFont:q,labelWeight:W}=Q,V=Q.labelColor.attribute?K[Q.labelColor.attribute]||Q.labelColor.color||"#000":Q.labelColor.color;J.fillStyle=V,J.font="".concat(W," ").concat(Z,"px ").concat(q),J.fillText(K.label,K.x+K.size+3,K.y+Z/3)}function rQ(J,K,Q){var{labelSize:Z,labelFont:q,labelWeight:W}=Q;J.font="".concat(W," ").concat(Z,"px ").concat(q),J.fillStyle="#FFF",J.shadowOffsetX=0,J.shadowOffsetY=0,J.shadowBlur=8,J.shadowColor="#000";var V=2;if(typeof K.label==="string"){var z=J.measureText(K.label).width,B=Math.round(z+5),H=Math.round(Z+2*V),Y=Math.max(K.size,Z/2)+V,$=Math.asin(H/2/Y),X=Math.sqrt(Math.abs(Math.pow(Y,2)-Math.pow(H/2,2)));J.beginPath(),J.moveTo(K.x+X,K.y+H/2),J.lineTo(K.x+Y+B,K.y+H/2),J.lineTo(K.x+Y+B,K.y-H/2),J.lineTo(K.x+X,K.y-H/2),J.arc(K.x,K.y,Y,$,-$),J.closePath(),J.fill()}else J.beginPath(),J.arc(K.x,K.y,K.size+V,0,Math.PI*2),J.closePath(),J.fill();J.shadowOffsetX=0,J.shadowOffsetY=0,J.shadowBlur=0,nJ(J,K,Q)}var _9=` -precision highp float; - -varying vec4 v_color; -varying vec2 v_diffVector; -varying float v_radius; - -uniform float u_correctionRatio; - -const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0); - -void main(void) { - float border = u_correctionRatio * 2.0; - float dist = length(v_diffVector) - v_radius + border; - - // No antialiasing for picking mode: - #ifdef PICKING_MODE - if (dist > border) - gl_FragColor = transparent; - else - gl_FragColor = v_color; - - #else - float t = 0.0; - if (dist > border) - t = 1.0; - else if (dist > 0.0) - t = dist / border; - - gl_FragColor = mix(v_color, transparent, t); - #endif -} -`,u9=_9,h9=` -attribute vec4 a_id; -attribute vec4 a_color; -attribute vec2 a_position; -attribute float a_size; -attribute float a_angle; - -uniform mat3 u_matrix; -uniform float u_sizeRatio; -uniform float u_correctionRatio; - -varying vec4 v_color; -varying vec2 v_diffVector; -varying float v_radius; -varying float v_border; - -const float bias = 255.0 / 254.0; - -void main() { - float size = a_size * u_correctionRatio / u_sizeRatio * 4.0; - vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle)); - vec2 position = a_position + diffVector; - gl_Position = vec4( - (u_matrix * vec3(position, 1)).xy, - 0, - 1 - ); - - v_diffVector = diffVector; - v_radius = size / 2.0; - - #ifdef PICKING_MODE - // For picking mode, we use the ID as the color: - v_color = a_id; - #else - // For normal mode, we use the color: - v_color = a_color; - #endif - - v_color.a *= bias; -} -`,m9=h9,aQ=WebGLRenderingContext,lQ=aQ.UNSIGNED_BYTE,pJ=aQ.FLOAT,c9=["u_sizeRatio","u_correctionRatio","u_matrix"],a0=function(J){function K(){return g(this,K),t(this,K,arguments)}return e(K,J),s(K,[{key:"getDefinition",value:function(){return{VERTICES:3,VERTEX_SHADER_SOURCE:m9,FRAGMENT_SHADER_SOURCE:u9,METHOD:WebGLRenderingContext.TRIANGLES,UNIFORMS:c9,ATTRIBUTES:[{name:"a_position",size:2,type:pJ},{name:"a_size",size:1,type:pJ},{name:"a_color",size:4,type:lQ,normalized:!0},{name:"a_id",size:4,type:lQ,normalized:!0}],CONSTANT_ATTRIBUTES:[{name:"a_angle",size:1,type:pJ}],CONSTANT_DATA:[[K.ANGLE_1],[K.ANGLE_2],[K.ANGLE_3]]}}},{key:"processVisibleItem",value:function(Z,q,W){var V=this.array,z=p0(W.color);V[q++]=W.x,V[q++]=W.y,V[q++]=W.size,V[q++]=z,V[q++]=Z}},{key:"setUniforms",value:function(Z,q){var{gl:W,uniformLocations:V}=q,z=V.u_sizeRatio,B=V.u_correctionRatio,H=V.u_matrix;W.uniform1f(B,Z.correctionRatio),W.uniform1f(z,Z.sizeRatio),W.uniformMatrix3fv(H,!1,Z.matrix)}}])}(E9);F(a0,"ANGLE_1",0);F(a0,"ANGLE_2",2*Math.PI/3);F(a0,"ANGLE_3",4*Math.PI/3);var l9=` -precision mediump float; - -varying vec4 v_color; - -void main(void) { - gl_FragColor = v_color; -} -`,p9=l9,g9=` -attribute vec2 a_position; -attribute vec2 a_normal; -attribute float a_radius; -attribute vec3 a_barycentric; - -#ifdef PICKING_MODE -attribute vec4 a_id; -#else -attribute vec4 a_color; -#endif - -uniform mat3 u_matrix; -uniform float u_sizeRatio; -uniform float u_correctionRatio; -uniform float u_minEdgeThickness; -uniform float u_lengthToThicknessRatio; -uniform float u_widenessToThicknessRatio; - -varying vec4 v_color; - -const float bias = 255.0 / 254.0; - -void main() { - float minThickness = u_minEdgeThickness; - - float normalLength = length(a_normal); - vec2 unitNormal = a_normal / normalLength; - - // These first computations are taken from edge.vert.glsl and - // edge.clamped.vert.glsl. Please read it to get better comments on what's - // happening: - float pixelsThickness = max(normalLength / u_sizeRatio, minThickness); - float webGLThickness = pixelsThickness * u_correctionRatio; - float webGLNodeRadius = a_radius * 2.0 * u_correctionRatio / u_sizeRatio; - float webGLArrowHeadLength = webGLThickness * u_lengthToThicknessRatio * 2.0; - float webGLArrowHeadThickness = webGLThickness * u_widenessToThicknessRatio; - - float da = a_barycentric.x; - float db = a_barycentric.y; - float dc = a_barycentric.z; - - vec2 delta = vec2( - da * (webGLNodeRadius * unitNormal.y) - + db * ((webGLNodeRadius + webGLArrowHeadLength) * unitNormal.y + webGLArrowHeadThickness * unitNormal.x) - + dc * ((webGLNodeRadius + webGLArrowHeadLength) * unitNormal.y - webGLArrowHeadThickness * unitNormal.x), - - da * (-webGLNodeRadius * unitNormal.x) - + db * (-(webGLNodeRadius + webGLArrowHeadLength) * unitNormal.x + webGLArrowHeadThickness * unitNormal.y) - + dc * (-(webGLNodeRadius + webGLArrowHeadLength) * unitNormal.x - webGLArrowHeadThickness * unitNormal.y) - ); - - vec2 position = (u_matrix * vec3(a_position + delta, 1)).xy; - - gl_Position = vec4(position, 0, 1); - - #ifdef PICKING_MODE - // For picking mode, we use the ID as the color: - v_color = a_id; - #else - // For normal mode, we use the color: - v_color = a_color; - #endif - - v_color.a *= bias; -} -`,s9=g9,tQ=WebGLRenderingContext,pQ=tQ.UNSIGNED_BYTE,zJ=tQ.FLOAT,n9=["u_matrix","u_sizeRatio","u_correctionRatio","u_minEdgeThickness","u_lengthToThicknessRatio","u_widenessToThicknessRatio"],eQ={extremity:"target",lengthToThicknessRatio:2.5,widenessToThicknessRatio:2};function J6(J){var K=v(v({},eQ),J||{});return function(Q){function Z(){return g(this,Z),t(this,Z,arguments)}return e(Z,Q),s(Z,[{key:"getDefinition",value:function(){return{VERTICES:3,VERTEX_SHADER_SOURCE:s9,FRAGMENT_SHADER_SOURCE:p9,METHOD:WebGLRenderingContext.TRIANGLES,UNIFORMS:n9,ATTRIBUTES:[{name:"a_position",size:2,type:zJ},{name:"a_normal",size:2,type:zJ},{name:"a_radius",size:1,type:zJ},{name:"a_color",size:4,type:pQ,normalized:!0},{name:"a_id",size:4,type:pQ,normalized:!0}],CONSTANT_ATTRIBUTES:[{name:"a_barycentric",size:3,type:zJ}],CONSTANT_DATA:[[1,0,0],[0,1,0],[0,0,1]]}}},{key:"processVisibleItem",value:function(W,V,z,B,H){if(K.extremity==="source"){var Y=[B,z];z=Y[0],B=Y[1]}var $=H.size||1,X=B.size||1,j=z.x,w=z.y,L=B.x,A=B.y,G=p0(H.color),k=L-j,T=A-w,P=k*k+T*T,O=0,M=0;if(P)P=1/Math.sqrt(P),O=-T*P*$,M=k*P*$;var I=this.array;I[V++]=L,I[V++]=A,I[V++]=-O,I[V++]=-M,I[V++]=X,I[V++]=G,I[V++]=W}},{key:"setUniforms",value:function(W,V){var{gl:z,uniformLocations:B}=V,H=B.u_matrix,Y=B.u_sizeRatio,$=B.u_correctionRatio,X=B.u_minEdgeThickness,j=B.u_lengthToThicknessRatio,w=B.u_widenessToThicknessRatio;z.uniformMatrix3fv(H,!1,W.matrix),z.uniform1f(Y,W.sizeRatio),z.uniform1f($,W.correctionRatio),z.uniform1f(X,W.minEdgeThickness),z.uniform1f(j,K.lengthToThicknessRatio),z.uniform1f(w,K.widenessToThicknessRatio)}}])}(sJ)}var a8=J6();var d9=` -precision mediump float; - -varying vec4 v_color; -varying vec2 v_normal; -varying float v_thickness; -varying float v_feather; - -const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0); - -void main(void) { - // We only handle antialiasing for normal mode: - #ifdef PICKING_MODE - gl_FragColor = v_color; - #else - float dist = length(v_normal) * v_thickness; - - float t = smoothstep( - v_thickness - v_feather, - v_thickness, - dist - ); - - gl_FragColor = mix(v_color, transparent, t); - #endif -} -`,Q6=d9,i9=` -attribute vec4 a_id; -attribute vec4 a_color; -attribute vec2 a_normal; -attribute float a_normalCoef; -attribute vec2 a_positionStart; -attribute vec2 a_positionEnd; -attribute float a_positionCoef; -attribute float a_radius; -attribute float a_radiusCoef; - -uniform mat3 u_matrix; -uniform float u_zoomRatio; -uniform float u_sizeRatio; -uniform float u_pixelRatio; -uniform float u_correctionRatio; -uniform float u_minEdgeThickness; -uniform float u_lengthToThicknessRatio; -uniform float u_feather; - -varying vec4 v_color; -varying vec2 v_normal; -varying float v_thickness; -varying float v_feather; - -const float bias = 255.0 / 254.0; - -void main() { - float minThickness = u_minEdgeThickness; - - float radius = a_radius * a_radiusCoef; - vec2 normal = a_normal * a_normalCoef; - vec2 position = a_positionStart * (1.0 - a_positionCoef) + a_positionEnd * a_positionCoef; - - float normalLength = length(normal); - vec2 unitNormal = normal / normalLength; - - // These first computations are taken from edge.vert.glsl. Please read it to - // get better comments on what's happening: - float pixelsThickness = max(normalLength, minThickness * u_sizeRatio); - float webGLThickness = pixelsThickness * u_correctionRatio / u_sizeRatio; - - // Here, we move the point to leave space for the arrow head: - float direction = sign(radius); - float webGLNodeRadius = direction * radius * 2.0 * u_correctionRatio / u_sizeRatio; - float webGLArrowHeadLength = webGLThickness * u_lengthToThicknessRatio * 2.0; - - vec2 compensationVector = vec2(-direction * unitNormal.y, direction * unitNormal.x) * (webGLNodeRadius + webGLArrowHeadLength); - - // Here is the proper position of the vertex - gl_Position = vec4((u_matrix * vec3(position + unitNormal * webGLThickness + compensationVector, 1)).xy, 0, 1); - - v_thickness = webGLThickness / u_zoomRatio; - - v_normal = unitNormal; - - v_feather = u_feather * u_correctionRatio / u_zoomRatio / u_pixelRatio * 2.0; - - #ifdef PICKING_MODE - // For picking mode, we use the ID as the color: - v_color = a_id; - #else - // For normal mode, we use the color: - v_color = a_color; - #endif - - v_color.a *= bias; -} -`,o9=i9,Z6=WebGLRenderingContext,gQ=Z6.UNSIGNED_BYTE,y0=Z6.FLOAT,r9=["u_matrix","u_zoomRatio","u_sizeRatio","u_correctionRatio","u_pixelRatio","u_feather","u_minEdgeThickness","u_lengthToThicknessRatio"],a9={lengthToThicknessRatio:eQ.lengthToThicknessRatio};function K6(J){var K=v(v({},a9),J||{});return function(Q){function Z(){return g(this,Z),t(this,Z,arguments)}return e(Z,Q),s(Z,[{key:"getDefinition",value:function(){return{VERTICES:6,VERTEX_SHADER_SOURCE:o9,FRAGMENT_SHADER_SOURCE:Q6,METHOD:WebGLRenderingContext.TRIANGLES,UNIFORMS:r9,ATTRIBUTES:[{name:"a_positionStart",size:2,type:y0},{name:"a_positionEnd",size:2,type:y0},{name:"a_normal",size:2,type:y0},{name:"a_color",size:4,type:gQ,normalized:!0},{name:"a_id",size:4,type:gQ,normalized:!0},{name:"a_radius",size:1,type:y0}],CONSTANT_ATTRIBUTES:[{name:"a_positionCoef",size:1,type:y0},{name:"a_normalCoef",size:1,type:y0},{name:"a_radiusCoef",size:1,type:y0}],CONSTANT_DATA:[[0,1,0],[0,-1,0],[1,1,1],[1,1,1],[0,-1,0],[1,-1,-1]]}}},{key:"processVisibleItem",value:function(W,V,z,B,H){var Y=H.size||1,$=z.x,X=z.y,j=B.x,w=B.y,L=p0(H.color),A=j-$,G=w-X,k=B.size||1,T=A*A+G*G,P=0,O=0;if(T)T=1/Math.sqrt(T),P=-G*T*Y,O=A*T*Y;var M=this.array;M[V++]=$,M[V++]=X,M[V++]=j,M[V++]=w,M[V++]=P,M[V++]=O,M[V++]=L,M[V++]=W,M[V++]=k}},{key:"setUniforms",value:function(W,V){var{gl:z,uniformLocations:B}=V,H=B.u_matrix,Y=B.u_zoomRatio,$=B.u_feather,X=B.u_pixelRatio,j=B.u_correctionRatio,w=B.u_sizeRatio,L=B.u_minEdgeThickness,A=B.u_lengthToThicknessRatio;z.uniformMatrix3fv(H,!1,W.matrix),z.uniform1f(Y,W.zoomRatio),z.uniform1f(w,W.sizeRatio),z.uniform1f(j,W.correctionRatio),z.uniform1f(X,W.pixelRatio),z.uniform1f($,W.antiAliasingFeather),z.uniform1f(L,W.minEdgeThickness),z.uniform1f(A,K.lengthToThicknessRatio)}}])}(sJ)}var t8=K6();function t9(J){return D9([K6(J),J6(J)])}var e9=t9(),q6=e9,J8=` -attribute vec4 a_id; -attribute vec4 a_color; -attribute vec2 a_normal; -attribute float a_normalCoef; -attribute vec2 a_positionStart; -attribute vec2 a_positionEnd; -attribute float a_positionCoef; - -uniform mat3 u_matrix; -uniform float u_sizeRatio; -uniform float u_zoomRatio; -uniform float u_pixelRatio; -uniform float u_correctionRatio; -uniform float u_minEdgeThickness; -uniform float u_feather; - -varying vec4 v_color; -varying vec2 v_normal; -varying float v_thickness; -varying float v_feather; - -const float bias = 255.0 / 254.0; - -void main() { - float minThickness = u_minEdgeThickness; - - vec2 normal = a_normal * a_normalCoef; - vec2 position = a_positionStart * (1.0 - a_positionCoef) + a_positionEnd * a_positionCoef; - - float normalLength = length(normal); - vec2 unitNormal = normal / normalLength; - - // We require edges to be at least "minThickness" pixels thick *on screen* - // (so we need to compensate the size ratio): - float pixelsThickness = max(normalLength, minThickness * u_sizeRatio); - - // Then, we need to retrieve the normalized thickness of the edge in the WebGL - // referential (in a ([0, 1], [0, 1]) space), using our "magic" correction - // ratio: - float webGLThickness = pixelsThickness * u_correctionRatio / u_sizeRatio; - - // Here is the proper position of the vertex - gl_Position = vec4((u_matrix * vec3(position + unitNormal * webGLThickness, 1)).xy, 0, 1); - - // For the fragment shader though, we need a thickness that takes the "magic" - // correction ratio into account (as in webGLThickness), but so that the - // antialiasing effect does not depend on the zoom level. So here's yet - // another thickness version: - v_thickness = webGLThickness / u_zoomRatio; - - v_normal = unitNormal; - - v_feather = u_feather * u_correctionRatio / u_zoomRatio / u_pixelRatio * 2.0; - - #ifdef PICKING_MODE - // For picking mode, we use the ID as the color: - v_color = a_id; - #else - // For normal mode, we use the color: - v_color = a_color; - #endif - - v_color.a *= bias; -} -`,Q8=J8,W6=WebGLRenderingContext,sQ=W6.UNSIGNED_BYTE,r0=W6.FLOAT,Z8=["u_matrix","u_zoomRatio","u_sizeRatio","u_correctionRatio","u_pixelRatio","u_feather","u_minEdgeThickness"],V6=function(J){function K(){return g(this,K),t(this,K,arguments)}return e(K,J),s(K,[{key:"getDefinition",value:function(){return{VERTICES:6,VERTEX_SHADER_SOURCE:Q8,FRAGMENT_SHADER_SOURCE:Q6,METHOD:WebGLRenderingContext.TRIANGLES,UNIFORMS:Z8,ATTRIBUTES:[{name:"a_positionStart",size:2,type:r0},{name:"a_positionEnd",size:2,type:r0},{name:"a_normal",size:2,type:r0},{name:"a_color",size:4,type:sQ,normalized:!0},{name:"a_id",size:4,type:sQ,normalized:!0}],CONSTANT_ATTRIBUTES:[{name:"a_positionCoef",size:1,type:r0},{name:"a_normalCoef",size:1,type:r0}],CONSTANT_DATA:[[0,1],[0,-1],[1,1],[1,1],[0,-1],[1,-1]]}}},{key:"processVisibleItem",value:function(Z,q,W,V,z){var B=z.size||1,H=W.x,Y=W.y,$=V.x,X=V.y,j=p0(z.color),w=$-H,L=X-Y,A=w*w+L*L,G=0,k=0;if(A)A=1/Math.sqrt(A),G=-L*A*B,k=w*A*B;var T=this.array;T[q++]=H,T[q++]=Y,T[q++]=$,T[q++]=X,T[q++]=G,T[q++]=k,T[q++]=j,T[q++]=Z}},{key:"setUniforms",value:function(Z,q){var{gl:W,uniformLocations:V}=q,z=V.u_matrix,B=V.u_zoomRatio,H=V.u_feather,Y=V.u_pixelRatio,$=V.u_correctionRatio,X=V.u_sizeRatio,j=V.u_minEdgeThickness;W.uniformMatrix3fv(z,!1,Z.matrix),W.uniform1f(B,Z.zoomRatio),W.uniform1f(X,Z.sizeRatio),W.uniform1f($,Z.correctionRatio),W.uniform1f(Y,Z.pixelRatio),W.uniform1f(H,Z.antiAliasingFeather),W.uniform1f(j,Z.minEdgeThickness)}}])}(sJ);var BJ=function(J){function K(){var Q;return g(this,K),Q=t(this,K),Q.rawEmitter=Q,Q}return e(K,J),s(K)}(w0);var H6=TJ(qJ(),1);var K8=function(K){return K},q8=function(K){return K*K},W8=function(K){return K*(2-K)},V8=function(K){if((K*=2)<1)return 0.5*K*K;return-0.5*(--K*(K-2)-1)},z8=function(K){return K*K*K},B8=function(K){return--K*K*K+1},H8=function(K){if((K*=2)<1)return 0.5*K*K*K;return 0.5*((K-=2)*K*K+2)},$6={linear:K8,quadraticIn:q8,quadraticOut:W8,quadraticInOut:V8,cubicIn:z8,cubicOut:B8,cubicInOut:H8},Y6={easing:"quadraticInOut",duration:150};function H0(){return Float32Array.of(1,0,0,0,1,0,0,0,1)}function HJ(J,K,Q){return J[0]=K,J[4]=typeof Q==="number"?Q:K,J}function z6(J,K){var Q=Math.sin(K),Z=Math.cos(K);return J[0]=Z,J[1]=Q,J[3]=-Q,J[4]=Z,J}function B6(J,K,Q){return J[6]=K,J[7]=Q,J}function G0(J,K){var Q=J[0],Z=J[1],q=J[2],W=J[3],V=J[4],z=J[5],B=J[6],H=J[7],Y=J[8],$=K[0],X=K[1],j=K[2],w=K[3],L=K[4],A=K[5],G=K[6],k=K[7],T=K[8];return J[0]=$*Q+X*W+j*B,J[1]=$*Z+X*V+j*H,J[2]=$*q+X*z+j*Y,J[3]=w*Q+L*W+A*B,J[4]=w*Z+L*V+A*H,J[5]=w*q+L*z+A*Y,J[6]=G*Q+k*W+T*B,J[7]=G*Z+k*V+T*H,J[8]=G*q+k*z+T*Y,J}function $J(J,K){var Q=arguments.length>2&&arguments[2]!==void 0?arguments[2]:1,Z=J[0],q=J[1],W=J[3],V=J[4],z=J[6],B=J[7],H=K.x,Y=K.y;return{x:H*Z+Y*W+z*Q,y:H*q+Y*V+B*Q}}function $8(J,K){var Q=J.height/J.width,Z=K.height/K.width;if(Q<1&&Z>1||Q>1&&Z<1)return 1;return Math.min(Math.max(Z,1/Z),Math.max(1/Q,Q))}function g0(J,K,Q,Z,q){var{angle:W,ratio:V,x:z,y:B}=J,H=K.width,Y=K.height,$=H0(),X=Math.min(H,Y)-2*Z,j=$8(K,Q);if(!q)G0($,HJ(H0(),2*(X/H)*j,2*(X/Y)*j)),G0($,z6(H0(),-W)),G0($,HJ(H0(),1/V)),G0($,B6(H0(),-z,-B));else G0($,B6(H0(),z,B)),G0($,HJ(H0(),V)),G0($,z6(H0(),W)),G0($,HJ(H0(),H/X/2/j,Y/X/2/j));return $}function X6(J,K,Q){var Z=$J(J,{x:Math.cos(K.angle),y:Math.sin(K.angle)},0),q=Z.x,W=Z.y;return 1/Math.sqrt(Math.pow(q,2)+Math.pow(W,2))/Q.width}function j6(J){if(!J.order)return{x:[0,1],y:[0,1]};var K=1/0,Q=-1/0,Z=1/0,q=-1/0;return J.forEachNode(function(W,V){var{x:z,y:B}=V;if(zQ)Q=z;if(Bq)q=B}),{x:[K,Q],y:[Z,q]}}function U6(J){if(!H6.default(J))throw Error("Sigma: invalid graph instance.");J.forEachNode(function(K,Q){if(!Number.isFinite(Q.x)||!Number.isFinite(Q.y))throw Error("Sigma: Coordinates of node ".concat(K," are invalid. A node must have a numeric 'x' and 'y' attribute."))})}function w6(J,K,Q){var Z=document.createElement(J);if(K)for(var q in K)Z.style[q]=K[q];if(Q)for(var W in Q)Z.setAttribute(W,Q[W]);return Z}function dJ(){if(typeof window.devicePixelRatio<"u")return window.devicePixelRatio;return 1}function iJ(J,K,Q){return Q.sort(function(Z,q){var W=K(Z)||0,V=K(q)||0;if(WV)return 1;return 0})}function oJ(J){var K=v0(J.x,2),Q=K[0],Z=K[1],q=v0(J.y,2),W=q[0],V=q[1],z=Math.max(Z-Q,V-W),B=(Z+Q)/2,H=(V+W)/2;if(z===0||Math.abs(z)===1/0||isNaN(z))z=1;if(isNaN(B))B=0;if(isNaN(H))H=0;var Y=function(X){return{x:0.5+(X.x-B)/z,y:0.5+(X.y-H)/z}};return Y.applyTo=function($){$.x=0.5+($.x-B)/z,$.y=0.5+($.y-H)/z},Y.inverse=function($){return{x:B+z*($.x-0.5),y:H+z*($.y-0.5)}},Y.ratio=z,Y}function YJ(J){return YJ=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(K){return typeof K}:function(K){return K&&typeof Symbol=="function"&&K.constructor===Symbol&&K!==Symbol.prototype?"symbol":typeof K},YJ(J)}function rJ(J,K){var Q=K.size;if(Q===0)return;var Z=J.length;J.length+=Q;var q=0;K.forEach(function(W){J[Z+q]=W,q++})}function XJ(J){J=J||{};for(var K=0,Q=arguments.length<=1?0:arguments.length-1;K1&&arguments[1]!==void 0?arguments[1]:{},V=arguments.length>2?arguments[2]:void 0;if(!V)return new Promise(function(j){return q.animate(Z,W,j)});if(!this.enabled)return;var z=v(v({},Y6),W),B=this.validateState(Z),H=typeof z.easing==="function"?z.easing:$6[z.easing],Y=Date.now(),$=this.getState(),X=function(){var w=(Date.now()-Y)/z.duration;if(w>=1){if(q.nextFrame=null,q.setState(B),q.animationCallback)q.animationCallback.call(null),q.animationCallback=void 0;return}var L=H(w),A={};if(typeof B.x==="number")A.x=$.x+(B.x-$.x)*L;if(typeof B.y==="number")A.y=$.y+(B.y-$.y)*L;if(q.enabledRotation&&typeof B.angle==="number")A.angle=$.angle+(B.angle-$.angle)*L;if(typeof B.ratio==="number")A.ratio=$.ratio+(B.ratio-$.ratio)*L;q.setState(A),q.nextFrame=requestAnimationFrame(X)};if(this.nextFrame){if(cancelAnimationFrame(this.nextFrame),this.animationCallback)this.animationCallback.call(null);this.nextFrame=requestAnimationFrame(X)}else X();this.animationCallback=V}},{key:"animatedZoom",value:function(Z){if(!Z)return this.animate({ratio:this.ratio/wJ});if(typeof Z==="number")return this.animate({ratio:this.ratio/Z});return this.animate({ratio:this.ratio/(Z.factor||wJ)},Z)}},{key:"animatedUnzoom",value:function(Z){if(!Z)return this.animate({ratio:this.ratio*wJ});if(typeof Z==="number")return this.animate({ratio:this.ratio*Z});return this.animate({ratio:this.ratio*(Z.factor||wJ)},Z)}},{key:"animatedReset",value:function(Z){return this.animate({x:0.5,y:0.5,ratio:1,angle:0},Z)}},{key:"copy",value:function(){return K.from(this.getState())}}],[{key:"from",value:function(Z){var q=new K;return q.setState(Z)}}])}(BJ);function U0(J,K){var Q=K.getBoundingClientRect();return{x:J.clientX-Q.left,y:J.clientY-Q.top}}function k0(J,K){var Q=v(v({},U0(J,K)),{},{sigmaDefaultPrevented:!1,preventSigmaDefault:function(){Q.sigmaDefaultPrevented=!0},original:J});return Q}function t0(J){var K="x"in J?J:v(v({},J.touches[0]||J.previousTouches[0]),{},{original:J.original,sigmaDefaultPrevented:J.sigmaDefaultPrevented,preventSigmaDefault:function(){J.sigmaDefaultPrevented=!0,K.sigmaDefaultPrevented=!0}});return K}function j8(J,K){return v(v({},k0(J,K)),{},{delta:k6(J)})}var U8=2;function FJ(J){var K=[];for(var Q=0,Z=Math.min(J.length,U8);Q0;if(q.draggedEvents=0,$&&q.renderer.getSetting("hideEdgesOnMove"))q.renderer.refresh()},0),this.emit("mouseup",k0(Z,this.container))}},{key:"handleMove",value:function(Z){var q=this;if(!this.enabled)return;var W=k0(Z,this.container);if(this.emit("mousemovebody",W),Z.target===this.container||Z.composedPath()[0]===this.container)this.emit("mousemove",W);if(W.sigmaDefaultPrevented)return;if(this.isMouseDown){if(this.isMoving=!0,this.draggedEvents++,typeof this.movingTimeout==="number")clearTimeout(this.movingTimeout);this.movingTimeout=window.setTimeout(function(){q.movingTimeout=null,q.isMoving=!1},this.settings.dragTimeout);var V=this.renderer.getCamera(),z=U0(Z,this.container),B=z.x,H=z.y,Y=this.renderer.viewportToFramedGraph({x:this.lastMouseX,y:this.lastMouseY}),$=this.renderer.viewportToFramedGraph({x:B,y:H}),X=Y.x-$.x,j=Y.y-$.y,w=V.getState(),L=w.x+X,A=w.y+j;V.setState({x:L,y:A}),this.lastMouseX=B,this.lastMouseY=H,Z.preventDefault(),Z.stopPropagation()}}},{key:"handleLeave",value:function(Z){this.emit("mouseleave",k0(Z,this.container))}},{key:"handleEnter",value:function(Z){this.emit("mouseenter",k0(Z,this.container))}},{key:"handleWheel",value:function(Z){var q=this,W=this.renderer.getCamera();if(!this.enabled||!W.enabledZooming)return;var V=k6(Z);if(!V)return;var z=j8(Z,this.container);if(this.emit("wheel",z),z.sigmaDefaultPrevented){Z.preventDefault(),Z.stopPropagation();return}var B=W.getState().ratio,H=V>0?1/this.settings.zoomingRatio:this.settings.zoomingRatio,Y=W.getBoundedRatio(B*H),$=V>0?1:-1,X=Date.now();if(B===Y)return;if(Z.preventDefault(),Z.stopPropagation(),this.currentWheelDirection===$&&this.lastWheelTriggerTime&&X-this.lastWheelTriggerTimeZ.size)return-1;if(Q.sizeZ.key)return 1;return-1}}])}(),C6=function(){function J(){g(this,J),F(this,"width",0),F(this,"height",0),F(this,"cellSize",0),F(this,"columns",0),F(this,"rows",0),F(this,"cells",{})}return s(J,[{key:"resizeAndClear",value:function(Q,Z){this.width=Q.width,this.height=Q.height,this.cellSize=Z,this.columns=Math.ceil(Q.width/Z),this.rows=Math.ceil(Q.height/Z),this.cells={}}},{key:"getIndex",value:function(Q){var Z=Math.floor(Q.x/this.cellSize),q=Math.floor(Q.y/this.cellSize);return q*this.columns+Z}},{key:"add",value:function(Q,Z,q){var W=new A6(Q,Z),V=this.getIndex(q),z=this.cells[V];if(!z)z=[],this.cells[V]=z;z.push(W)}},{key:"organize",value:function(){for(var Q in this.cells){var Z=this.cells[Q];Z.sort(A6.compare)}}},{key:"getLabelsToDisplay",value:function(Q,Z){var q=this.cellSize*this.cellSize,W=q/Q/Q,V=W*Z/q,z=Math.ceil(V),B=[];for(var H in this.cells){var Y=this.cells[H];for(var $=0;$2&&arguments[2]!==void 0?arguments[2]:{};if(g(this,K),q=t(this,K),F(q,"elements",{}),F(q,"canvasContexts",{}),F(q,"webGLContexts",{}),F(q,"pickingLayers",new Set),F(q,"textures",{}),F(q,"frameBuffers",{}),F(q,"activeListeners",{}),F(q,"labelGrid",new C6),F(q,"nodeDataCache",{}),F(q,"edgeDataCache",{}),F(q,"nodeProgramIndex",{}),F(q,"edgeProgramIndex",{}),F(q,"nodesWithForcedLabels",new Set),F(q,"edgesWithForcedLabels",new Set),F(q,"nodeExtent",{x:[0,1],y:[0,1]}),F(q,"nodeZExtent",[1/0,-1/0]),F(q,"edgeZExtent",[1/0,-1/0]),F(q,"matrix",H0()),F(q,"invMatrix",H0()),F(q,"correctionRatio",1),F(q,"customBBox",null),F(q,"normalizationFunction",oJ({x:[0,1],y:[0,1]})),F(q,"graphToViewportRatio",1),F(q,"itemIDsIndex",{}),F(q,"nodeIndices",{}),F(q,"edgeIndices",{}),F(q,"width",0),F(q,"height",0),F(q,"pixelRatio",dJ()),F(q,"pickingDownSizingRatio",2*q.pixelRatio),F(q,"displayedNodeLabels",new Set),F(q,"displayedEdgeLabels",new Set),F(q,"highlightedNodes",new Set),F(q,"hoveredNode",null),F(q,"hoveredEdge",null),F(q,"renderFrame",null),F(q,"renderHighlightedNodesFrame",null),F(q,"needToProcess",!1),F(q,"checkEdgesEventsFrame",null),F(q,"nodePrograms",{}),F(q,"nodeHoverPrograms",{}),F(q,"edgePrograms",{}),q.settings=F6(W),UJ(q.settings),U6(Q),!(Z instanceof HTMLElement))throw Error("Sigma: container should be an html element.");q.graph=Q,q.container=Z,q.createWebGLContext("edges",{picking:W.enableEdgeEvents}),q.createCanvasContext("edgeLabels"),q.createWebGLContext("nodes",{picking:!0}),q.createCanvasContext("labels"),q.createCanvasContext("hovers"),q.createWebGLContext("hoverNodes"),q.createCanvasContext("mouse",{style:{touchAction:"none",userSelect:"none"}}),q.resize();for(var V in q.settings.nodeProgramClasses)q.registerNodeProgram(V,q.settings.nodeProgramClasses[V],q.settings.nodeHoverProgramClasses[V]);for(var z in q.settings.edgeProgramClasses)q.registerEdgeProgram(z,q.settings.edgeProgramClasses[z]);return q.camera=new L6,q.bindCameraHandlers(),q.mouseCaptor=new L8(q.elements.mouse,q),q.mouseCaptor.setSettings(q.settings),q.touchCaptor=new C8(q.elements.mouse,q),q.touchCaptor.setSettings(q.settings),q.bindEventHandlers(),q.bindGraphHandlers(),q.handleSettingsUpdate(),q.refresh(),q}return e(K,J),s(K,[{key:"registerNodeProgram",value:function(Z,q,W){if(this.nodePrograms[Z])this.nodePrograms[Z].kill();if(this.nodeHoverPrograms[Z])this.nodeHoverPrograms[Z].kill();return this.nodePrograms[Z]=new q(this.webGLContexts.nodes,this.frameBuffers.nodes,this),this.nodeHoverPrograms[Z]=new(W||q)(this.webGLContexts.hoverNodes,null,this),this}},{key:"registerEdgeProgram",value:function(Z,q){if(this.edgePrograms[Z])this.edgePrograms[Z].kill();return this.edgePrograms[Z]=new q(this.webGLContexts.edges,this.frameBuffers.edges,this),this}},{key:"unregisterNodeProgram",value:function(Z){if(this.nodePrograms[Z]){var q=this.nodePrograms,W=q[Z],V=aJ(q,[Z].map(N0));W.kill(),this.nodePrograms=V}if(this.nodeHoverPrograms[Z]){var z=this.nodeHoverPrograms,B=z[Z],H=aJ(z,[Z].map(N0));B.kill(),this.nodePrograms=H}return this}},{key:"unregisterEdgeProgram",value:function(Z){if(this.edgePrograms[Z]){var q=this.edgePrograms,W=q[Z],V=aJ(q,[Z].map(N0));W.kill(),this.edgePrograms=V}return this}},{key:"resetWebGLTexture",value:function(Z){var q=this.webGLContexts[Z],W=this.frameBuffers[Z],V=this.textures[Z];if(V)q.deleteTexture(V);var z=q.createTexture();return q.bindFramebuffer(q.FRAMEBUFFER,W),q.bindTexture(q.TEXTURE_2D,z),q.texImage2D(q.TEXTURE_2D,0,q.RGBA,this.width,this.height,0,q.RGBA,q.UNSIGNED_BYTE,null),q.framebufferTexture2D(q.FRAMEBUFFER,q.COLOR_ATTACHMENT0,q.TEXTURE_2D,z,0),this.textures[Z]=z,this}},{key:"bindCameraHandlers",value:function(){var Z=this;return this.activeListeners.camera=function(){Z.scheduleRender()},this.camera.on("updated",this.activeListeners.camera),this}},{key:"unbindCameraHandlers",value:function(){return this.camera.removeListener("updated",this.activeListeners.camera),this}},{key:"getNodeAtPosition",value:function(Z){var{x:q,y:W}=Z,V=cJ(this.webGLContexts.nodes,this.frameBuffers.nodes,q,W,this.pixelRatio,this.pickingDownSizingRatio),z=mJ.apply(void 0,T6(V)),B=this.itemIDsIndex[z];return B&&B.type==="node"?B.id:null}},{key:"bindEventHandlers",value:function(){var Z=this;this.activeListeners.handleResize=function(){Z.scheduleRefresh()},window.addEventListener("resize",this.activeListeners.handleResize),this.activeListeners.handleMove=function(W){var V=t0(W),z={event:V,preventSigmaDefault:function(){V.preventSigmaDefault()}},B=Z.getNodeAtPosition(V);if(B&&Z.hoveredNode!==B&&!Z.nodeDataCache[B].hidden){if(Z.hoveredNode)Z.emit("leaveNode",v(v({},z),{},{node:Z.hoveredNode}));Z.hoveredNode=B,Z.emit("enterNode",v(v({},z),{},{node:B})),Z.scheduleHighlightedNodesRender();return}if(Z.hoveredNode){if(Z.getNodeAtPosition(V)!==Z.hoveredNode){var H=Z.hoveredNode;Z.hoveredNode=null,Z.emit("leaveNode",v(v({},z),{},{node:H})),Z.scheduleHighlightedNodesRender();return}}if(Z.settings.enableEdgeEvents){var Y=Z.hoveredNode?null:Z.getEdgeAtPoint(z.event.x,z.event.y);if(Y!==Z.hoveredEdge){if(Z.hoveredEdge)Z.emit("leaveEdge",v(v({},z),{},{edge:Z.hoveredEdge}));if(Y)Z.emit("enterEdge",v(v({},z),{},{edge:Y}));Z.hoveredEdge=Y}}},this.activeListeners.handleMoveBody=function(W){var V=t0(W);Z.emit("moveBody",{event:V,preventSigmaDefault:function(){V.preventSigmaDefault()}})},this.activeListeners.handleLeave=function(W){var V=t0(W),z={event:V,preventSigmaDefault:function(){V.preventSigmaDefault()}};if(Z.hoveredNode)Z.emit("leaveNode",v(v({},z),{},{node:Z.hoveredNode})),Z.scheduleHighlightedNodesRender();if(Z.settings.enableEdgeEvents&&Z.hoveredEdge)Z.emit("leaveEdge",v(v({},z),{},{edge:Z.hoveredEdge})),Z.scheduleHighlightedNodesRender();Z.emit("leaveStage",v({},z))},this.activeListeners.handleEnter=function(W){var V=t0(W),z={event:V,preventSigmaDefault:function(){V.preventSigmaDefault()}};Z.emit("enterStage",v({},z))};var q=function(V){return function(z){var B=t0(z),H={event:B,preventSigmaDefault:function(){B.preventSigmaDefault()}},Y=Z.getNodeAtPosition(B);if(Y)return Z.emit("".concat(V,"Node"),v(v({},H),{},{node:Y}));if(Z.settings.enableEdgeEvents){var $=Z.getEdgeAtPoint(B.x,B.y);if($)return Z.emit("".concat(V,"Edge"),v(v({},H),{},{edge:$}))}return Z.emit("".concat(V,"Stage"),H)}};return this.activeListeners.handleClick=q("click"),this.activeListeners.handleRightClick=q("rightClick"),this.activeListeners.handleDoubleClick=q("doubleClick"),this.activeListeners.handleWheel=q("wheel"),this.activeListeners.handleDown=q("down"),this.activeListeners.handleUp=q("up"),this.mouseCaptor.on("mousemove",this.activeListeners.handleMove),this.mouseCaptor.on("mousemovebody",this.activeListeners.handleMoveBody),this.mouseCaptor.on("click",this.activeListeners.handleClick),this.mouseCaptor.on("rightClick",this.activeListeners.handleRightClick),this.mouseCaptor.on("doubleClick",this.activeListeners.handleDoubleClick),this.mouseCaptor.on("wheel",this.activeListeners.handleWheel),this.mouseCaptor.on("mousedown",this.activeListeners.handleDown),this.mouseCaptor.on("mouseup",this.activeListeners.handleUp),this.mouseCaptor.on("mouseleave",this.activeListeners.handleLeave),this.mouseCaptor.on("mouseenter",this.activeListeners.handleEnter),this.touchCaptor.on("touchdown",this.activeListeners.handleDown),this.touchCaptor.on("touchdown",this.activeListeners.handleMove),this.touchCaptor.on("touchup",this.activeListeners.handleUp),this.touchCaptor.on("touchmove",this.activeListeners.handleMove),this.touchCaptor.on("tap",this.activeListeners.handleClick),this.touchCaptor.on("doubletap",this.activeListeners.handleDoubleClick),this.touchCaptor.on("touchmove",this.activeListeners.handleMoveBody),this}},{key:"bindGraphHandlers",value:function(){var Z=this,q=this.graph,W=new Set(["x","y","zIndex","type"]);return this.activeListeners.eachNodeAttributesUpdatedGraphUpdate=function(V){var z,B=(z=V.hints)===null||z===void 0?void 0:z.attributes;Z.graph.forEachNode(function(Y){return Z.updateNode(Y)});var H=!B||B.some(function(Y){return W.has(Y)});Z.refresh({partialGraph:{nodes:q.nodes()},skipIndexation:!H,schedule:!0})},this.activeListeners.eachEdgeAttributesUpdatedGraphUpdate=function(V){var z,B=(z=V.hints)===null||z===void 0?void 0:z.attributes;Z.graph.forEachEdge(function(Y){return Z.updateEdge(Y)});var H=B&&["zIndex","type"].some(function(Y){return B===null||B===void 0?void 0:B.includes(Y)});Z.refresh({partialGraph:{edges:q.edges()},skipIndexation:!H,schedule:!0})},this.activeListeners.addNodeGraphUpdate=function(V){var z=V.key;Z.addNode(z),Z.refresh({partialGraph:{nodes:[z]},skipIndexation:!1,schedule:!0})},this.activeListeners.updateNodeGraphUpdate=function(V){var z=V.key;Z.refresh({partialGraph:{nodes:[z]},skipIndexation:!1,schedule:!0})},this.activeListeners.dropNodeGraphUpdate=function(V){var z=V.key;Z.removeNode(z),Z.refresh({schedule:!0})},this.activeListeners.addEdgeGraphUpdate=function(V){var z=V.key;Z.addEdge(z),Z.refresh({partialGraph:{edges:[z]},schedule:!0})},this.activeListeners.updateEdgeGraphUpdate=function(V){var z=V.key;Z.refresh({partialGraph:{edges:[z]},skipIndexation:!1,schedule:!0})},this.activeListeners.dropEdgeGraphUpdate=function(V){var z=V.key;Z.removeEdge(z),Z.refresh({schedule:!0})},this.activeListeners.clearEdgesGraphUpdate=function(){Z.clearEdgeState(),Z.clearEdgeIndices(),Z.refresh({schedule:!0})},this.activeListeners.clearGraphUpdate=function(){Z.clearEdgeState(),Z.clearNodeState(),Z.clearEdgeIndices(),Z.clearNodeIndices(),Z.refresh({schedule:!0})},q.on("nodeAdded",this.activeListeners.addNodeGraphUpdate),q.on("nodeDropped",this.activeListeners.dropNodeGraphUpdate),q.on("nodeAttributesUpdated",this.activeListeners.updateNodeGraphUpdate),q.on("eachNodeAttributesUpdated",this.activeListeners.eachNodeAttributesUpdatedGraphUpdate),q.on("edgeAdded",this.activeListeners.addEdgeGraphUpdate),q.on("edgeDropped",this.activeListeners.dropEdgeGraphUpdate),q.on("edgeAttributesUpdated",this.activeListeners.updateEdgeGraphUpdate),q.on("eachEdgeAttributesUpdated",this.activeListeners.eachEdgeAttributesUpdatedGraphUpdate),q.on("edgesCleared",this.activeListeners.clearEdgesGraphUpdate),q.on("cleared",this.activeListeners.clearGraphUpdate),this}},{key:"unbindGraphHandlers",value:function(){var Z=this.graph;Z.removeListener("nodeAdded",this.activeListeners.addNodeGraphUpdate),Z.removeListener("nodeDropped",this.activeListeners.dropNodeGraphUpdate),Z.removeListener("nodeAttributesUpdated",this.activeListeners.updateNodeGraphUpdate),Z.removeListener("eachNodeAttributesUpdated",this.activeListeners.eachNodeAttributesUpdatedGraphUpdate),Z.removeListener("edgeAdded",this.activeListeners.addEdgeGraphUpdate),Z.removeListener("edgeDropped",this.activeListeners.dropEdgeGraphUpdate),Z.removeListener("edgeAttributesUpdated",this.activeListeners.updateEdgeGraphUpdate),Z.removeListener("eachEdgeAttributesUpdated",this.activeListeners.eachEdgeAttributesUpdatedGraphUpdate),Z.removeListener("edgesCleared",this.activeListeners.clearEdgesGraphUpdate),Z.removeListener("cleared",this.activeListeners.clearGraphUpdate)}},{key:"getEdgeAtPoint",value:function(Z,q){var W=cJ(this.webGLContexts.edges,this.frameBuffers.edges,Z,q,this.pixelRatio,this.pickingDownSizingRatio),V=mJ.apply(void 0,T6(W)),z=this.itemIDsIndex[V];return z&&z.type==="edge"?z.id:null}},{key:"process",value:function(){var Z=this;this.emit("beforeProcess");var q=this.graph,W=this.settings,V=this.getDimensions();if(this.nodeExtent=j6(this.graph),!this.settings.autoRescale){var{width:z,height:B}=V,H=this.nodeExtent,Y=H.x,$=H.y;this.nodeExtent={x:[(Y[0]+Y[1])/2-z/2,(Y[0]+Y[1])/2+z/2],y:[($[0]+$[1])/2-B/2,($[0]+$[1])/2+B/2]}}this.normalizationFunction=oJ(this.customBBox||this.nodeExtent);var X=new L6,j=g0(X.getState(),V,this.getGraphDimensions(),this.getStagePadding());this.labelGrid.resizeAndClear(V,W.labelGridCellSize);var w={},L={},A={},G={},k=1,T=q.nodes();for(var P=0,O=T.length;P1&&arguments[1]!==void 0?arguments[1]:{},W=q.tolerance,V=W===void 0?0:W,z=q.boundaries,B=v({},Z),H=z||this.nodeExtent,Y=v0(H.x,2),$=Y[0],X=Y[1],j=v0(H.y,2),w=j[0],L=j[1],A=[this.graphToViewport({x:$,y:w},{cameraState:Z}),this.graphToViewport({x:X,y:w},{cameraState:Z}),this.graphToViewport({x:$,y:L},{cameraState:Z}),this.graphToViewport({x:X,y:L},{cameraState:Z})],G=1/0,k=-1/0,T=1/0,P=-1/0;A.forEach(function(E){var{x:n,y:q0}=E;G=Math.min(G,n),k=Math.max(k,n),T=Math.min(T,q0),P=Math.max(P,q0)});var O=k-G,M=P-T,I=this.getDimensions(),R=I.width,U=I.height,u=0,c=0;if(O>=R){if(kV)u=G-V}else if(k>R+V)u=k-(R+V);else if(G<-V)u=G+V;if(M>=U){if(PV)c=T-V}else if(P>U+V)c=P-(U+V);else if(T<-V)c=T+V;if(u||c){var h=this.viewportToFramedGraph({x:0,y:0},{cameraState:Z}),J0=this.viewportToFramedGraph({x:u,y:c},{cameraState:Z});u=J0.x-h.x,c=J0.y-h.y,B.x+=u,B.y+=c}return B}},{key:"renderLabels",value:function(){if(!this.settings.renderLabels)return this;var Z=this.camera.getState(),q=this.labelGrid.getLabelsToDisplay(Z.ratio,this.settings.labelDensity);rJ(q,this.nodesWithForcedLabels),this.displayedNodeLabels=new Set;var W=this.canvasContexts.labels;for(var V=0,z=q.length;Vthis.width+P6||X<-I6||X>this.height+I6)continue;this.displayedNodeLabels.add(B);var w=this.settings.defaultDrawNodeLabel,L=this.nodePrograms[H.type],A=(L===null||L===void 0?void 0:L.drawLabel)||w;A(W,v(v({key:B},H),{},{size:j,x:$,y:X}),this.settings)}return this}},{key:"renderEdgeLabels",value:function(){if(!this.settings.renderEdgeLabels)return this;var Z=this.canvasContexts.edgeLabels;Z.clearRect(0,0,this.width,this.height);var q=R8({graph:this.graph,hoveredNode:this.hoveredNode,displayedNodeLabels:this.displayedNodeLabels,highlightedNodes:this.highlightedNodes});rJ(q,this.edgesWithForcedLabels);var W=new Set;for(var V=0,z=q.length;Vthis.nodeZExtent[1])this.nodeZExtent[1]=W.zIndex}}},{key:"updateNode",value:function(Z){this.addNode(Z);var q=this.nodeDataCache[Z];this.normalizationFunction.applyTo(q)}},{key:"removeNode",value:function(Z){if(delete this.nodeDataCache[Z],delete this.nodeProgramIndex[Z],this.highlightedNodes.delete(Z),this.hoveredNode===Z)this.hoveredNode=null;this.nodesWithForcedLabels.delete(Z)}},{key:"addEdge",value:function(Z){var q=Object.assign({},this.graph.getEdgeAttributes(Z));if(this.settings.edgeReducer)q=this.settings.edgeReducer(Z,q);var W=G8(this.settings,Z,q);if(this.edgeDataCache[Z]=W,this.edgesWithForcedLabels.delete(Z),W.forceLabel&&!W.hidden)this.edgesWithForcedLabels.add(Z);if(this.settings.zIndex){if(W.zIndexthis.edgeZExtent[1])this.edgeZExtent[1]=W.zIndex}}},{key:"updateEdge",value:function(Z){this.addEdge(Z)}},{key:"removeEdge",value:function(Z){if(delete this.edgeDataCache[Z],delete this.edgeProgramIndex[Z],this.hoveredEdge===Z)this.hoveredEdge=null;this.edgesWithForcedLabels.delete(Z)}},{key:"clearNodeIndices",value:function(){this.labelGrid=new C6,this.nodeExtent={x:[0,1],y:[0,1]},this.nodeDataCache={},this.edgeProgramIndex={},this.nodesWithForcedLabels=new Set,this.nodeZExtent=[1/0,-1/0],this.highlightedNodes=new Set}},{key:"clearEdgeIndices",value:function(){this.edgeDataCache={},this.edgeProgramIndex={},this.edgesWithForcedLabels=new Set,this.edgeZExtent=[1/0,-1/0]}},{key:"clearIndices",value:function(){this.clearEdgeIndices(),this.clearNodeIndices()}},{key:"clearNodeState",value:function(){this.displayedNodeLabels=new Set,this.highlightedNodes=new Set,this.hoveredNode=null}},{key:"clearEdgeState",value:function(){this.displayedEdgeLabels=new Set,this.highlightedNodes=new Set,this.hoveredEdge=null}},{key:"clearState",value:function(){this.clearEdgeState(),this.clearNodeState()}},{key:"addNodeToProgram",value:function(Z,q,W){var V=this.nodeDataCache[Z],z=this.nodePrograms[V.type];if(!z)throw Error('Sigma: could not find a suitable program for node type "'.concat(V.type,'"!'));z.process(q,W,V),this.nodeProgramIndex[Z]=W}},{key:"addEdgeToProgram",value:function(Z,q,W){var V=this.edgeDataCache[Z],z=this.edgePrograms[V.type];if(!z)throw Error('Sigma: could not find a suitable program for edge type "'.concat(V.type,'"!'));var B=this.graph.extremities(Z),H=this.nodeDataCache[B[0]],Y=this.nodeDataCache[B[1]];z.process(q,W,H,Y,V),this.edgeProgramIndex[Z]=W}},{key:"getRenderParams",value:function(){return{matrix:this.matrix,invMatrix:this.invMatrix,width:this.width,height:this.height,pixelRatio:this.pixelRatio,zoomRatio:this.camera.ratio,cameraAngle:this.camera.angle,sizeRatio:1/this.scaleSize(),correctionRatio:this.correctionRatio,downSizingRatio:this.pickingDownSizingRatio,minEdgeThickness:this.settings.minEdgeThickness,antiAliasingFeather:this.settings.antiAliasingFeather}}},{key:"getStagePadding",value:function(){var Z=this.settings,q=Z.stagePadding,W=Z.autoRescale;return W?q||0:0}},{key:"createLayer",value:function(Z,q){var W=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};if(this.elements[Z])throw Error('Sigma: a layer named "'.concat(Z,'" already exists'));var V=w6(q,{position:"absolute"},{class:"sigma-".concat(Z)});if(W.style)Object.assign(V.style,W.style);if(this.elements[Z]=V,"beforeLayer"in W&&W.beforeLayer)this.elements[W.beforeLayer].before(V);else if("afterLayer"in W&&W.afterLayer)this.elements[W.afterLayer].after(V);else this.container.appendChild(V);return V}},{key:"createCanvas",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};return this.createLayer(Z,"canvas",q)}},{key:"createCanvasContext",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},W=this.createCanvas(Z,q),V={preserveDrawingBuffer:!1,antialias:!1};return this.canvasContexts[Z]=W.getContext("2d",V),this}},{key:"createWebGLContext",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},W=(q===null||q===void 0?void 0:q.canvas)||this.createCanvas(Z,q);if(q.hidden)W.remove();var V=v({preserveDrawingBuffer:!1,antialias:!1},q),z;if(z=W.getContext("webgl2",V),!z)z=W.getContext("webgl",V);if(!z)z=W.getContext("experimental-webgl",V);var B=z;if(this.webGLContexts[Z]=B,B.blendFunc(B.ONE,B.ONE_MINUS_SRC_ALPHA),q.picking){this.pickingLayers.add(Z);var H=B.createFramebuffer();if(!H)throw Error("Sigma: cannot create a new frame buffer for layer ".concat(Z));this.frameBuffers[Z]=H}return B}},{key:"killLayer",value:function(Z){var q=this.elements[Z];if(!q)throw Error("Sigma: cannot kill layer ".concat(Z,", which does not exist"));if(this.webGLContexts[Z]){var W,V=this.webGLContexts[Z];(W=V.getExtension("WEBGL_lose_context"))===null||W===void 0||W.loseContext(),delete this.webGLContexts[Z]}else if(this.canvasContexts[Z])delete this.canvasContexts[Z];return q.remove(),delete this.elements[Z],this}},{key:"getCamera",value:function(){return this.camera}},{key:"setCamera",value:function(Z){this.unbindCameraHandlers(),this.camera=Z,this.bindCameraHandlers()}},{key:"getContainer",value:function(){return this.container}},{key:"getGraph",value:function(){return this.graph}},{key:"setGraph",value:function(Z){if(Z===this.graph)return;if(this.hoveredNode&&!Z.hasNode(this.hoveredNode))this.hoveredNode=null;if(this.hoveredEdge&&!Z.hasEdge(this.hoveredEdge))this.hoveredEdge=null;if(this.unbindGraphHandlers(),this.checkEdgesEventsFrame!==null)cancelAnimationFrame(this.checkEdgesEventsFrame),this.checkEdgesEventsFrame=null;this.graph=Z,this.bindGraphHandlers(),this.refresh()}},{key:"getMouseCaptor",value:function(){return this.mouseCaptor}},{key:"getTouchCaptor",value:function(){return this.touchCaptor}},{key:"getDimensions",value:function(){return{width:this.width,height:this.height}}},{key:"getGraphDimensions",value:function(){var Z=this.customBBox||this.nodeExtent;return{width:Z.x[1]-Z.x[0]||1,height:Z.y[1]-Z.y[0]||1}}},{key:"getNodeDisplayData",value:function(Z){var q=this.nodeDataCache[Z];return q?Object.assign({},q):void 0}},{key:"getEdgeDisplayData",value:function(Z){var q=this.edgeDataCache[Z];return q?Object.assign({},q):void 0}},{key:"getNodeDisplayedLabels",value:function(){return new Set(this.displayedNodeLabels)}},{key:"getEdgeDisplayedLabels",value:function(){return new Set(this.displayedEdgeLabels)}},{key:"getSettings",value:function(){return v({},this.settings)}},{key:"getSetting",value:function(Z){return this.settings[Z]}},{key:"setSetting",value:function(Z,q){var W=v({},this.settings);return this.settings[Z]=q,UJ(this.settings),this.handleSettingsUpdate(W),this.scheduleRefresh(),this}},{key:"updateSetting",value:function(Z,q){return this.setSetting(Z,q(this.settings[Z])),this}},{key:"setSettings",value:function(Z){var q=v({},this.settings);return this.settings=v(v({},this.settings),Z),UJ(this.settings),this.handleSettingsUpdate(q),this.scheduleRefresh(),this}},{key:"resize",value:function(Z){var q=this.width,W=this.height;if(this.width=this.container.offsetWidth,this.height=this.container.offsetHeight,this.pixelRatio=dJ(),this.width===0)if(this.settings.allowInvalidContainer)this.width=1;else throw Error("Sigma: Container has no width. You can set the allowInvalidContainer setting to true to stop seeing this error.");if(this.height===0)if(this.settings.allowInvalidContainer)this.height=1;else throw Error("Sigma: Container has no height. You can set the allowInvalidContainer setting to true to stop seeing this error.");if(!Z&&q===this.width&&W===this.height)return this;for(var V in this.elements){var z=this.elements[V];z.style.width=this.width+"px",z.style.height=this.height+"px"}for(var B in this.canvasContexts)if(this.elements[B].setAttribute("width",this.width*this.pixelRatio+"px"),this.elements[B].setAttribute("height",this.height*this.pixelRatio+"px"),this.pixelRatio!==1)this.canvasContexts[B].scale(this.pixelRatio,this.pixelRatio);for(var H in this.webGLContexts){this.elements[H].setAttribute("width",this.width*this.pixelRatio+"px"),this.elements[H].setAttribute("height",this.height*this.pixelRatio+"px");var Y=this.webGLContexts[H];if(Y.viewport(0,0,this.width*this.pixelRatio,this.height*this.pixelRatio),this.pickingLayers.has(H)){var $=this.textures[H];if($)Y.deleteTexture($)}}return this.emit("resize"),this}},{key:"clear",value:function(){return this.emit("beforeClear"),this.webGLContexts.nodes.bindFramebuffer(WebGLRenderingContext.FRAMEBUFFER,null),this.webGLContexts.nodes.clear(WebGLRenderingContext.COLOR_BUFFER_BIT),this.webGLContexts.edges.bindFramebuffer(WebGLRenderingContext.FRAMEBUFFER,null),this.webGLContexts.edges.clear(WebGLRenderingContext.COLOR_BUFFER_BIT),this.webGLContexts.hoverNodes.clear(WebGLRenderingContext.COLOR_BUFFER_BIT),this.canvasContexts.labels.clearRect(0,0,this.width,this.height),this.canvasContexts.hovers.clearRect(0,0,this.width,this.height),this.canvasContexts.edgeLabels.clearRect(0,0,this.width,this.height),this.emit("afterClear"),this}},{key:"refresh",value:function(Z){var q=this,W=(Z===null||Z===void 0?void 0:Z.skipIndexation)!==void 0?Z===null||Z===void 0?void 0:Z.skipIndexation:!1,V=(Z===null||Z===void 0?void 0:Z.schedule)!==void 0?Z.schedule:!1,z=!Z||!Z.partialGraph;if(z)this.clearEdgeIndices(),this.clearNodeIndices(),this.graph.forEachNode(function(P){return q.addNode(P)}),this.graph.forEachEdge(function(P){return q.addEdge(P)});else{var B,H,Y=((B=Z.partialGraph)===null||B===void 0?void 0:B.nodes)||[];for(var $=0,X=(Y===null||Y===void 0?void 0:Y.length)||0;$1&&arguments[1]!==void 0?arguments[1]:{},W=!!q.cameraState||!!q.viewportDimensions||!!q.graphDimensions,V=q.matrix?q.matrix:W?g0(q.cameraState||this.camera.getState(),q.viewportDimensions||this.getDimensions(),q.graphDimensions||this.getGraphDimensions(),q.padding||this.getStagePadding()):this.matrix,z=$J(V,Z);return{x:(1+z.x)*this.width/2,y:(1-z.y)*this.height/2}}},{key:"viewportToFramedGraph",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},W=!!q.cameraState||!!q.viewportDimensions||!q.graphDimensions,V=q.matrix?q.matrix:W?g0(q.cameraState||this.camera.getState(),q.viewportDimensions||this.getDimensions(),q.graphDimensions||this.getGraphDimensions(),q.padding||this.getStagePadding(),!0):this.invMatrix,z=$J(V,{x:Z.x/this.width*2-1,y:1-Z.y/this.height*2});if(isNaN(z.x))z.x=0;if(isNaN(z.y))z.y=0;return z}},{key:"viewportToGraph",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};return this.normalizationFunction.inverse(this.viewportToFramedGraph(Z,q))}},{key:"graphToViewport",value:function(Z){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};return this.framedGraphToViewport(this.normalizationFunction(Z),q)}},{key:"getGraphToViewportRatio",value:function(){var Z={x:0,y:0},q={x:1,y:1},W=Math.sqrt(Math.pow(Z.x-q.x,2)+Math.pow(Z.y-q.y,2)),V=this.graphToViewport(Z),z=this.graphToViewport(q),B=Math.sqrt(Math.pow(V.x-z.x,2)+Math.pow(V.y-z.y,2));return B/W}},{key:"getBBox",value:function(){return this.nodeExtent}},{key:"getCustomBBox",value:function(){return this.customBBox}},{key:"setCustomBBox",value:function(Z){return this.customBBox=Z,this.scheduleRender(),this}},{key:"kill",value:function(){if(this.emit("kill"),this.removeAllListeners(),this.unbindCameraHandlers(),window.removeEventListener("resize",this.activeListeners.handleResize),this.mouseCaptor.kill(),this.touchCaptor.kill(),this.unbindGraphHandlers(),this.clearIndices(),this.clearState(),this.nodeDataCache={},this.edgeDataCache={},this.highlightedNodes.clear(),this.renderFrame)cancelAnimationFrame(this.renderFrame),this.renderFrame=null;if(this.renderHighlightedNodesFrame)cancelAnimationFrame(this.renderHighlightedNodesFrame),this.renderHighlightedNodesFrame=null;var Z=this.container;while(Z.firstChild)Z.removeChild(Z.firstChild);for(var q in this.nodePrograms)this.nodePrograms[q].kill();for(var W in this.nodeHoverPrograms)this.nodeHoverPrograms[W].kill();for(var V in this.edgePrograms)this.edgePrograms[V].kill();this.nodePrograms={},this.nodeHoverPrograms={},this.edgePrograms={};for(var z in this.elements)this.killLayer(z);this.canvasContexts={},this.webGLContexts={},this.elements={}}},{key:"scaleSize",value:function(){var Z=arguments.length>0&&arguments[0]!==void 0?arguments[0]:1,q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.camera.ratio;return Z/this.settings.zoomToSizeRatioFunction(q)*(this.getSetting("itemSizesReference")==="positions"?q*this.graphToViewportRatio:1)}},{key:"getCanvases",value:function(){var Z={};for(var q in this.elements)if(this.elements[q]instanceof HTMLCanvasElement)Z[q]=this.elements[q];return Z}}])}(BJ),R6=O8;var f8={nodes:[],edges:[]};function M8(){let J=window.__KEG__;if(!J||!Array.isArray(J.nodes)||!Array.isArray(J.edges))return f8;return J}function b8(){let J=document.getElementById("panel");if(J)return J;let K=document.createElement("aside");return K.id="panel",K.className="hidden",document.body.appendChild(K),K}function N8(J){J.innerHTML=` -
-

KEG Graph

-

No nodes found in dex indexes.

-
- `}function y8(J){let K=new Map;for(let Q of J.nodes){if(!Q||typeof Q.id!=="string"||Q.id.trim()==="")continue;K.set(Q.id,{id:Q.id,label:Q.label||Q.id,summary:Q.summary||"",tags:Array.isArray(Q.tags)?Q.tags:[],url:Q.url||""})}return K}function x8(J){if(J==="backlink")return"rgba(71, 85, 105, 0.42)";return"rgba(30, 64, 175, 0.62)"}function E8(J){if(J==="backlink")return"line";return"arrow"}function D8(){let J=document.getElementById("app");if(!J)return;let K=M8();if(K.nodes.length===0){N8(J);return}let Q=y8(K),Z=new x({multi:!0,type:"directed"}),q=new Map,W=Array.from(Q.values()),V=W.length,z=Math.max(20,Math.sqrt(V)*12);W.forEach((j,w)=>{let L=w/Math.max(V,1)*Math.PI*2,A=1+Math.floor(w/180),G=Math.cos(L)*z*A*0.25,k=Math.sin(L)*z*A*0.25;q.set(j.id,0),Z.addNode(j.id,{x:G,y:k,label:j.label||j.id,size:4,color:"#1f5aa6",data:j})});let B=0;if(K.edges.forEach((j,w)=>{if(!j||!j.source||!j.target)return;if(!Z.hasNode(j.source))Z.addNode(j.source,{x:0,y:0,label:j.source,size:3,color:"#64748b",data:{id:j.source,label:j.source,summary:"",tags:[],url:""}}),q.set(j.source,q.get(j.source)??0);if(!Z.hasNode(j.target))Z.addNode(j.target,{x:0,y:0,label:j.target,size:3,color:"#64748b",data:{id:j.target,label:j.target,summary:"",tags:[],url:""}}),q.set(j.target,q.get(j.target)??0);let L=`${j.source}->${j.target}:${j.type}:${w}`;Z.addEdgeWithKey(L,j.source,j.target,{color:x8(j.type),size:j.type==="backlink"?0.55:0.95,type:E8(j.type),data:j}),q.set(j.source,(q.get(j.source)??0)+1),q.set(j.target,(q.get(j.target)??0)+1),B++}),Z.forEachNode((j)=>{let w=q.get(j)??0;Z.setNodeAttribute(j,"size",2.4+Math.min(10,Math.sqrt(w+1)))}),Z.order>1&&Z.order<=2600&&B>0)tJ.default.assign(Z,{iterations:80,settings:tJ.default.inferSettings(Z)});let H=b8(),Y=new R6(Z,J,{renderLabels:!0,labelRenderedSizeThreshold:9,defaultEdgeType:"arrow",defaultNodeColor:"#1f5aa6",defaultEdgeColor:"rgba(30, 64, 175, 0.62)",defaultDrawEdgeLabels:!1,enableEdgeEvents:!1});function $(){H.classList.add("hidden"),H.innerHTML=""}function X(j){let w=Z.getNodeAttributes(j),L=w.data??{id:j,label:w.label||j,summary:"",tags:[],url:""},A=Z.outDegree(j),G=Z.inDegree(j),k=Array.isArray(L.tags)?L.tags:[],T=k.length>0?k.join(", "):"none",P=L.summary?.trim()||"No summary available.",O=L.url&&L.url.trim()!==""?`

Open node

`:"";H.innerHTML=` -

${L.label||L.id}

-

${P}

-

ID: ${L.id}

-

Tags: ${T}

-

Outgoing: ${A}    Incoming: ${G}

- ${O} - `,H.classList.remove("hidden")}Y.on("clickNode",({node:j})=>{X(j)}),Y.on("clickStage",()=>{$()})}D8(); diff --git a/pkg/cli/auth_prompt.go b/pkg/cli/auth_prompt.go index 63bf77f6..fd12515b 100644 --- a/pkg/cli/auth_prompt.go +++ b/pkg/cli/auth_prompt.go @@ -159,9 +159,8 @@ func validateNonEmpty(s string) error { } // buildHubChoices assembles the interactive hub picker: atlas first, then every -// non-local hub configured in cfg (deduped by canonical URL, sorted by name for -// a stable menu), then an "Other endpoint" row. Local hubs are excluded — you -// don't log in to a filesystem hub. +// remote hub configured in cfg (deduped by canonical URL, sorted by name for +// a stable menu), then an "Other endpoint" row. func buildHubChoices(cfg *tapper.Config) []hubChoice { var choices []hubChoice seen := map[string]bool{} @@ -185,7 +184,7 @@ func buildHubChoices(cfg *tapper.Config) []hubChoice { sort.Strings(names) for _, name := range names { h := cfg.Hubs()[name] - if h.Kind == tapper.HubKindLocal || strings.TrimSpace(h.URL) == "" { + if strings.TrimSpace(h.URL) == "" { continue } add(fmt.Sprintf("%s — %s", name, hostOf(ensureScheme(h.URL))), h.URL) diff --git a/pkg/cli/auth_prompt_test.go b/pkg/cli/auth_prompt_test.go index 128c9309..0efa6943 100644 --- a/pkg/cli/auth_prompt_test.go +++ b/pkg/cli/auth_prompt_test.go @@ -29,7 +29,7 @@ func TestBuildHubChoices_RemoteHubsOnly_DedupAndScheme(t *testing.T) { t.Parallel() cfg := &tapper.Config{} require.NoError(t, cfg.SetHub("keg-example", tapper.HubEntry{Kind: tapper.HubKindRemote, URL: "keg.example.com"})) // bare host - require.NoError(t, cfg.SetHub("home", tapper.HubEntry{Kind: tapper.HubKindLocal, BasePath: "/tmp/kegs"})) // excluded + require.NoError(t, cfg.SetHub("home", tapper.HubEntry{Kind: "local"})) // unsupported, excluded require.NoError(t, cfg.SetHub("atlas-again", tapper.HubEntry{Kind: tapper.HubKindRemote, URL: "https://atlas.foldwise.ai"})) choices := buildHubChoices(cfg) diff --git a/pkg/cli/bootstrap_prompt.go b/pkg/cli/bootstrap_prompt.go index 5993443c..b09f3f69 100644 --- a/pkg/cli/bootstrap_prompt.go +++ b/pkg/cli/bootstrap_prompt.go @@ -28,7 +28,7 @@ type bootstrapDefaultKegSelection struct { // BootstrapPrompter is the interactive surface of `tap bootstrap`. type BootstrapPrompter interface { - // SelectBootstrapKind chooses the cloud, local, or enterprise bootstrap path. + // SelectBootstrapKind chooses the cloud or enterprise bootstrap path. SelectBootstrapKind() (string, error) // PromptBootstrapEndpoint collects the enterprise hub endpoint URL. PromptBootstrapEndpoint() (string, error) @@ -73,7 +73,6 @@ func (huhAuthPrompter) SelectBootstrapKind() (string, error) { Title("Where should your kegs live?"). Options( huh.NewOption("Cloud - atlas.foldwise.ai", tapper.BootstrapKindCloud), - huh.NewOption("Local - this machine only", tapper.BootstrapKindLocal), huh.NewOption("Enterprise - your own hub", tapper.BootstrapKindEnterprise), ). Value(&kind), diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index e3fd703c..3fa6bae3 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -38,6 +38,13 @@ func WithTestDepsHook(ctx context.Context, hook func(*Deps)) context.Context { if hook == nil { return ctx } + if previous := testDepsHookFromContext(ctx); previous != nil { + next := hook + hook = func(deps *Deps) { + previous(deps) + next(deps) + } + } return context.WithValue(ctx, testDepsHookKey{}, hook) } diff --git a/pkg/cli/cmd_bootstrap.go b/pkg/cli/cmd_bootstrap.go index 30225d12..561f22d3 100644 --- a/pkg/cli/cmd_bootstrap.go +++ b/pkg/cli/cmd_bootstrap.go @@ -1,7 +1,7 @@ package cli // `tap bootstrap` — first-run onboarding. Walks the user through a deployment -// kind (local / cloud / enterprise), writes a usable user config, and +// kind (cloud / enterprise), writes a usable user config, and // optionally drives a hub login by reusing runAuthLogin. CLI-only: login and // the conversational prompt are not agent operations, so there is no MCP // surface (Tap.Bootstrap is listed in pkg/parity's tapMethodsExcluded). @@ -25,7 +25,6 @@ import ( // Usage examples: // // tap bootstrap # interactive on a TTY -// tap bootstrap --kind local # local filesystem hub only // tap bootstrap --kind cloud # atlas.foldwise.ai // tap bootstrap --kind enterprise --endpoint https://keg.acme.com func NewBootstrapCmd(deps *Deps) *cobra.Command { @@ -46,21 +45,18 @@ func NewBootstrapCmd(deps *Deps) *cobra.Command { Set up your user-level tapper config so plain commands resolve without per-invocation flags. Choose where your kegs live: - local a filesystem hub on this machine (no account, no login) cloud atlas.foldwise.ai, the hosted hub enterprise a self-hosted hub at a URL you provide -Bootstrap writes the matching fallback hub and ensures the built-in local hub -is always available. The namespace comes from the hub itself: @local for local, -and your home namespace (adopted at login) for cloud/enterprise. It is -idempotent: re-running preserves any kegs and keg-map entries you already have. +Bootstrap writes the matching fallback hub. The namespace comes from the hub +itself and is adopted at login. It is idempotent: re-running preserves any +kegs and keg-map entries you already have. It can also record a user-level flight baseline for MCP sessions; project config, TAP_FLIGHT, and an explicit --flight on later commands override it. On a TTY with no flags, bootstrap prompts for the kind (and, for enterprise, the endpoint), then offers to log in. Pass --non-interactive to rely on flags. -For cloud/enterprise, --login / --no-login control the login step; local never -logs in. +--login / --no-login control the login step. `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -179,35 +175,6 @@ logs in. createdKegLocation = created } - // 4c. For a local deployment, create the chosen keg now so the user - // is immediately up and running — plain `tap` commands work without a - // separate `tap keg create`. Remote bootstrap creates during the - // interactive chooser above only after a successful login; explicit - // --default-keg stays a recorded default. Idempotent: a keg that - // already exists is fine. - if chosenKeg != "" && createdKegLocation == "" && res.Kind == tapper.BootstrapKindLocal { - ns, name, perr := parseKegArg(chosenKeg) - if perr != nil { - _, _ = fmt.Fprintf(stderr, "warning: could not create keg %q: %v\n", chosenKeg, perr) - } else { - target, cerr := deps.Tap.InitKeg(ctx, tapper.InitOptions{ - Keg: name, - Namespace: ns, - NonInteractive: true, - }) - switch { - case cerr == nil: - if target != nil { - createdKegLocation = bootstrapCreatedKegSummary(bootstrapKegRef(ns, name, res.Namespace), target) - } - case errors.Is(cerr, keg.ErrExist): - // Already exists — the user is still ready to go. - default: - _, _ = fmt.Fprintf(stderr, "warning: could not create keg %q: %v\n", chosenKeg, cerr) - } - } - } - if chosenKeg != "" { if serr := deps.Tap.SetFallbackKeg(ctx, chosenKeg); serr != nil { _, _ = fmt.Fprintf(stderr, "warning: could not set default keg: %v\n", serr) @@ -293,7 +260,7 @@ logs in. }, } - cmd.Flags().StringVar(&kind, "kind", "", "deployment kind: local | cloud | enterprise") + cmd.Flags().StringVar(&kind, "kind", "", "deployment kind: cloud | enterprise") cmd.Flags().StringVar(&endpoint, "endpoint", "", "enterprise hub endpoint URL (required for --kind enterprise)") cmd.Flags().StringVar(&hubName, "hub-name", "", "name to record an enterprise hub under (default: derived from the endpoint host)") cmd.Flags().StringVar(&defaultKeg, "default-keg", "", "keg reference plain `tap` commands resolve by default (e.g. @you/notes); recorded as the user-level fallbackKeg so a project's defaultKeg or kegMap can override; prompts on a TTY when unset") @@ -302,7 +269,7 @@ logs in. cmd.Flags().BoolVar(&nonInteractive, "non-interactive", false, "skip interactive prompts even when stdin is a TTY") mustRegisterFlagCompletion(cmd, "kind", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - kinds := []string{tapper.BootstrapKindLocal, tapper.BootstrapKindCloud, tapper.BootstrapKindEnterprise} + kinds := []string{tapper.BootstrapKindCloud, tapper.BootstrapKindEnterprise} return filterByPrefix(kinds, toComplete), cobra.ShellCompDirectiveNoFileComp }) mustRegisterFlagCompletion(cmd, "endpoint", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -360,12 +327,10 @@ func parseBootstrapKind(s string) (string, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "", "c", "cloud": return tapper.BootstrapKindCloud, nil - case "l", "local": - return tapper.BootstrapKindLocal, nil case "e", "enterprise": return tapper.BootstrapKindEnterprise, nil default: - return "", fmt.Errorf("invalid kind %q: expected local, cloud, or enterprise", s) + return "", fmt.Errorf("invalid kind %q: expected cloud or enterprise", s) } } @@ -471,7 +436,6 @@ func bootstrapCreateRemoteDefaultKeg(ctx context.Context, deps *Deps, res *tappe Keg: alias, Hub: res.Hub, Namespace: namespace, - NonInteractive: true, RequireBootstrap: true, }) switch { diff --git a/pkg/cli/cmd_bootstrap_test.go b/pkg/cli/cmd_bootstrap_test.go index 5fd59527..da2f3d71 100644 --- a/pkg/cli/cmd_bootstrap_test.go +++ b/pkg/cli/cmd_bootstrap_test.go @@ -130,107 +130,30 @@ func TestBootstrapCmd_NonInteractive_DefaultsToCloud(t *testing.T) { "namespace comes from the hub, not a global fallback") } -func TestBootstrapCmd_Local_NoLogin(t *testing.T) { +func TestBootstrapCmd_RejectsRemovedLocalKind(t *testing.T) { t.Parallel() sb := newTestSandbox(t) - hook := stubDeviceLoginHook(func(context.Context, *toolkit.Runtime, tapper.AuthLoginDeviceOptions) (*tapper.AuthEntry, error) { - t.Fatal("local bootstrap must never log in") - return nil, nil - }) - - proc := newBootstrapProcess(t, hook, false, "bootstrap", "--kind", "local") - res := proc.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "fallback hub: testhost") - require.Contains(t, string(res.Stdout), "namespace: local", - "a local deployment defaults to the @local namespace, not the OS user") - require.NotContains(t, string(res.Stdout), "tap auth login") - - raw := sb.MustReadFile("~/.config/tapper/config.yaml") - require.Contains(t, string(raw), "fallbackHub: testhost") - require.NotContains(t, string(raw), "fallbackNamespace:", - "namespace comes from the local hub, not a global fallback") - require.Contains(t, string(raw), "defaultNamespace: local", - "the local hub carries the @local namespace") -} - -// TestBootstrapCmd_Local_CreatesDefaultKeg confirms a local bootstrap actually -// creates the chosen keg on disk (so the user is immediately up and running) and -// that re-running is idempotent. -func TestBootstrapCmd_Local_CreatesDefaultKeg(t *testing.T) { - t.Parallel() - sb := newTestSandbox(t) - - proc := newBootstrapProcess(t, nil, false, "bootstrap", "--kind", "local", "--default-keg", "private") + proc := newBootstrapProcess(t, nil, false, "bootstrap", "--kind", "local") res := proc.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "created keg:", - "local bootstrap should create the chosen keg so the user is ready") - require.Contains(t, string(res.Stdout), "@local/private") - - // The keg was materialized on disk at the local hub's basePath. - kegFile := sb.MustReadFile("~/.local/share/tapper/kegs/@local/private/keg") - require.Contains(t, string(kegFile), "kegv") - - // Re-running is idempotent: the keg already exists, so no error and it is not - // reported as freshly created. - proc2 := newBootstrapProcess(t, nil, false, "bootstrap", "--kind", "local", "--default-keg", "private") - res2 := proc2.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res2.Err) - require.NotContains(t, string(res2.Stdout), "created keg:", - "an already-existing keg should not be reported as created") -} - -func TestBootstrapCmd_Interactive_SelectsFlightAndPreservesItOnSkip(t *testing.T) { - t.Parallel() - sb := newTestSandbox(t) - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/.local/share/tapper/kegs/flights.d/focused.yaml", - []byte("title: Focused\n"), 0o644)) - - firstPrompter := &fakeBootstrapPrompter{ - t: t, - manualKeg: func() (string, error) { return "", nil }, - selectFlight: func(available []string, current string) (string, error) { - require.Equal(t, []string{"@local/+focused"}, available) - require.Empty(t, current) - return "@local/+focused", nil - }, - } - first := newBootstrapProcess(t, stubBootstrapPrompterHook(firstPrompter), true, - "bootstrap", "--kind", "local") - res := first.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "stderr=%q", string(res.Stderr)) - require.Contains(t, string(res.Stdout), "flight: @local/+focused") - require.Contains(t, string(sb.MustReadFile("~/.config/tapper/config.yaml")), "flight: '@local/+focused'") - - secondPrompter := &fakeBootstrapPrompter{ - t: t, - manualKeg: func() (string, error) { return "", nil }, - selectFlight: func(available []string, current string) (string, error) { - require.Equal(t, []string{"@local/+focused"}, available) - require.Equal(t, "@local/+focused", current, "existing baseline should be preselected") - return "", nil // Skip for now. - }, - } - second := newBootstrapProcess(t, stubBootstrapPrompterHook(secondPrompter), true, - "bootstrap", "--kind", "local") - res = second.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "stderr=%q", string(res.Stderr)) - require.Contains(t, string(sb.MustReadFile("~/.config/tapper/config.yaml")), "flight: '@local/+focused'", - "Skip must preserve the existing baseline") + require.Error(t, res.Err) + require.Contains(t, res.Err.Error(), "expected cloud or enterprise") } func TestBootstrapCmd_NoFlightsReportsRecoveryOnly(t *testing.T) { t.Parallel() sb := newTestSandbox(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/flights", r.URL.Path) + _ = json.NewEncoder(w).Encode([]tapper.HubFlight{}) + })) + defer srv.Close() prompter := &fakeBootstrapPrompter{ t: t, manualKeg: func() (string, error) { return "", nil }, } proc := newBootstrapProcess(t, stubBootstrapPrompterHook(prompter), true, - "bootstrap", "--kind", "local") + "bootstrap", "--kind", "enterprise", "--endpoint", srv.URL, "--no-login") res := proc.Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) require.Contains(t, string(res.Stdout), "flight: recovery-only") @@ -238,33 +161,12 @@ func TestBootstrapCmd_NoFlightsReportsRecoveryOnly(t *testing.T) { require.NotContains(t, string(sb.MustReadFile("~/.config/tapper/config.yaml")), "flight:") } -func TestBootstrapCmd_ExplicitFlightValidatesAndPersists(t *testing.T) { - t.Parallel() - sb := newTestSandbox(t) - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/.local/share/tapper/kegs/flights.d/focused.yaml", - []byte("title: Focused\n"), 0o644)) - - proc := newBootstrapProcess(t, nil, false, - "bootstrap", "--kind", "local", "--flight", "+focused") - res := proc.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "stderr=%q", string(res.Stderr)) - require.Contains(t, string(res.Stdout), "flight: @local/+focused") - require.Contains(t, string(sb.MustReadFile("~/.config/tapper/config.yaml")), "flight: '@local/+focused'") - - bad := newBootstrapProcess(t, nil, false, - "bootstrap", "--kind", "local", "--flight", "+missing") - badRes := bad.Run(sb.Context(), sb.Runtime()) - require.Error(t, badRes.Err) - require.Contains(t, badRes.Err.Error(), "invalid bootstrap flight") -} - func TestBootstrapCmd_ImplicitFlightOverrideIsNotPersisted(t *testing.T) { t.Parallel() sb := newTestSandbox(t) - require.NoError(t, sb.Runtime().Env().Set("TAP_FLIGHT", "@local/+environment")) + require.NoError(t, sb.Runtime().Env().Set("TAP_FLIGHT", "@team/+environment")) - proc := newBootstrapProcess(t, nil, false, "bootstrap", "--kind", "local") + proc := newBootstrapProcess(t, nil, false, "bootstrap", "--kind", "cloud", "--no-login") res := proc.Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) require.NotContains(t, string(sb.MustReadFile("~/.config/tapper/config.yaml")), "flight:", @@ -562,17 +464,13 @@ func TestBootstrapCmd_Enterprise_NonInteractiveRequiresEndpoint(t *testing.T) { require.Contains(t, res.Err.Error(), "endpoint") } -// TestBootstrapCmd_ProfileGate confirms bootstrap is registered for `tap` -// (IncludeConfigCommand) and absent from the pruned `keg` profile. -func TestBootstrapCmd_ProfileGate(t *testing.T) { +func TestBootstrapCmdRegistered(t *testing.T) { t.Parallel() sb := newTestSandbox(t) tapCmds := commandNames(t, sb.Runtime(), TapProfile()) require.True(t, tapCmds["bootstrap"], "tap should expose the bootstrap command") - kegCmds := commandNames(t, sb.Runtime(), KegProfile()) - require.False(t, kegCmds["bootstrap"], "keg must not expose the bootstrap command") } // --- completion --- @@ -582,7 +480,7 @@ func TestBootstrapCompletion_KindFlag_ListsKinds(t *testing.T) { res := runCompletionViaProcess(t, "bootstrap", "--kind", "") require.NoError(t, res.Err) out := string(res.Stdout) - require.Contains(t, out, "local") + require.NotContains(t, out, "local") require.Contains(t, out, "cloud") require.Contains(t, out, "enterprise") } diff --git a/pkg/cli/cmd_cat_test.go b/pkg/cli/cmd_cat_test.go index 886a7cf2..4c7f3a25 100644 --- a/pkg/cli/cmd_cat_test.go +++ b/pkg/cli/cmd_cat_test.go @@ -1,21 +1,16 @@ package cli_test import ( - "encoding/json" "os" "path/filepath" "strings" "testing" + "time" testutils "github.com/jlrickert/cli-toolkit/sandbox" "github.com/stretchr/testify/require" ) -type catStatsJSON struct { - Accessed string `json:"accessed"` - AccessCount int `json:"access_count"` -} - type catTestCase struct { name string args []string @@ -45,8 +40,8 @@ func TestCatCommand_TableDrivenErrorHandling(t *testing.T) { name: "cat_nonexistent_alias", args: []string{"cat", "0", "--keg", "nonexistent"}, setupFixture: strPtr("joe"), - expectedErr: "node 0 not found", - description: "Error when keg does not exist on disk", + expectedErr: "keg not initialized", + description: "Error when the remote keg does not exist", }, { name: "cat_nonexistent_node", @@ -267,65 +262,17 @@ func TestCatCommand_WithJoeFixture(t *testing.T) { } func TestCatCommand_IntegrationWithInit(t *testing.T) { - t.Run("cat_node_after_init", func(innerT *testing.T) { - innerT.Parallel() - opts := []testutils.Option{ - testutils.WithFixture("testuser", "~"), - } - sb := NewSandbox(innerT, opts...) - - // First, initialize a user keg - initCmd := NewProcess(innerT, false, - "init", - "--user", - "--keg", "newstudy", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "init should succeed") - require.Contains(innerT, string(initRes.Stdout), "keg newstudy created") - - // Now cat the node 0 - catCmd := NewProcess(innerT, false, "cat", "0", "--keg", "newstudy") - catRes := catCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, catRes.Err, "cat should succeed") - - stdout := string(catRes.Stdout) - require.Contains(innerT, stdout, "---", "output should contain frontmatter") - require.NotContains(innerT, stdout, "access_count:", "frontmatter should not inject stats") - require.Contains(innerT, stdout, "Sorry, planned but not yet available", "output should contain content") - }) + sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) + res := NewProcess(t, false, "init").Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + require.Contains(t, string(res.Stderr), `unknown command "init"`) } func TestCatCommand_UserKeg(t *testing.T) { - t.Run("cat_from_user_keg_with_alias", func(innerT *testing.T) { - innerT.Parallel() - opts := []testutils.Option{ - testutils.WithFixture("testuser", "~"), - } - sb := NewSandbox(innerT, opts...) - - // First, initialize a user keg - initCmd := NewProcess(innerT, false, - "init", - "--user", - "--keg", "public", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "init should succeed") - require.Contains(innerT, string(initRes.Stdout), "keg public created") - - // Now cat the node from that user keg - catCmd := NewProcess(innerT, false, "cat", "0", "--keg", "public") - catRes := catCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, catRes.Err, "cat should succeed") - - stdout := string(catRes.Stdout) - require.Contains(innerT, stdout, "---", "output should contain frontmatter") - require.NotContains(innerT, stdout, "access_count:", "frontmatter should not inject stats") - require.Contains(innerT, stdout, "Sorry, planned but not yet available", "content should be present") - }) + sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) + res := NewProcess(t, false, "cat", "0", "--keg", "public").Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + require.Contains(t, string(res.Stderr), "keg not initialized") } func TestCatCommand_BumpsAccessedAndAccessCount(t *testing.T) { @@ -340,18 +287,16 @@ func TestCatCommand_BumpsAccessedAndAccessCount(t *testing.T) { res := h.Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err, "cat should succeed and bump access metadata") - var afterOne catStatsJSON - require.NoError(t, json.Unmarshal(sb.MustReadFile(statsPath), &afterOne)) - require.Equal(t, 8, afterOne.AccessCount, "access count should increment on read") - require.NotEmpty(t, afterOne.Accessed, "accessed should be set") - require.NotEqual(t, oldAccessed, afterOne.Accessed, "accessed should be bumped") + afterOne := fixtureStats(t, sb.Runtime(), "personal", "0") + require.Equal(t, 8, afterOne.AccessCount(), "access count should increment on read") + require.False(t, afterOne.Accessed().IsZero(), "accessed should be set") + require.NotEqual(t, oldAccessed, afterOne.Accessed().Format(time.RFC3339), "accessed should be bumped") res = h.Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err, "second cat should also succeed") - var afterTwo catStatsJSON - require.NoError(t, json.Unmarshal(sb.MustReadFile(statsPath), &afterTwo)) - require.Equal(t, 9, afterTwo.AccessCount, "access count should increment on every read") + afterTwo := fixtureStats(t, sb.Runtime(), "personal", "0") + require.Equal(t, 9, afterTwo.AccessCount(), "access count should increment on every read") } func TestCatCommand_DefaultFrontmatterDoesNotInjectStats(t *testing.T) { @@ -401,8 +346,8 @@ EOF require.NoError(t, res.Err) require.Equal(t, "", strings.TrimSpace(string(res.Stdout))) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - content := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") + content := fixtureContent(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "- edited-via-cat") require.Contains(t, meta, "summary: changed by cat edit") require.Contains(t, content, "# Cat Edited") @@ -549,10 +494,10 @@ EOF "interactive TTY cat should not print to stdout") // Verify editor was invoked by checking the node was modified. - content := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + content := fixtureContent(t, sb.Runtime(), "personal", "0") require.Contains(t, content, "# TTY Cat Edit", "editor should have modified the node content") - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "- tty-edited", "editor should have modified the node metadata") } @@ -684,8 +629,7 @@ func TestCatCommand_TTY_BumpsAccessCount(t *testing.T) { RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - var stats catStatsJSON - require.NoError(t, json.Unmarshal(sb.MustReadFile(statsPath), &stats)) - require.Equal(t, 6, stats.AccessCount, + stats := fixtureStats(t, sb.Runtime(), "personal", "0") + require.Equal(t, 6, stats.AccessCount(), "interactive TTY cat should bump access_count") } diff --git a/pkg/cli/cmd_config_test.go b/pkg/cli/cmd_config_test.go index d47d67d7..547fbf43 100644 --- a/pkg/cli/cmd_config_test.go +++ b/pkg/cli/cmd_config_test.go @@ -5,6 +5,7 @@ import ( "testing" testutils "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/pkg/schemas" "github.com/stretchr/testify/require" ) @@ -38,20 +39,17 @@ func TestConfigCommand_DisplaysMergedConfig(t *testing.T) { args: []string{"config", "template", "user"}, setupFixture: strPtr("joe"), expectedInStdout: []string{ - "# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/tap-config.json", "fallbackHub:", - "namespaces:", "defaultNamespace: pub", "hubs:", }, - description: "User template should include the fallback hub, the local namespace mapping, the per-hub namespace, and the hubs map", + description: "User template should include the fallback hub, per-hub namespace, and hubs map", }, { name: "config_template_project_includes_new_keys", args: []string{"config", "template", "project"}, setupFixture: strPtr("joe"), expectedInStdout: []string{ - "# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/tap-config.json", "defaultKeg:", "defaultHub:", "defaultNamespace:", @@ -87,8 +85,11 @@ func TestConfigCommand_DisplaysMergedConfig(t *testing.T) { } if strings.Contains(strings.Join(tt.args, " "), "template") { - require.True(innerT, strings.HasPrefix(stdout, "# yaml-language-server: $schema="), - "template output should start with yaml-language-server modeline") + // The modeline resolves to the schema copy materialized + // from this binary, not the URL published on main. + require.True(innerT, strings.HasPrefix(stdout, + schemas.ModelinePrefix+schemas.ModelineURI(sb.Runtime(), schemas.TapConfig)+"\n"), + "template output should start with a modeline pointing at the local schema, got:\n%s", stdout) } // Verify it looks like YAML output @@ -100,40 +101,10 @@ func TestConfigCommand_DisplaysMergedConfig(t *testing.T) { } func TestConfigCommand_IntegrationWithInit(t *testing.T) { - t.Run("config_after_init", func(innerT *testing.T) { - innerT.Parallel() - opts := []testutils.Option{ - testutils.WithFixture("testuser", "~"), - } - sb := NewSandbox(innerT, opts...) - - // First, initialize a user keg. With the namespace-centric model the - // keg lands on the local hub on disk; init no longer mutates the user - // config to register an alias. - initCmd := NewProcess(innerT, false, - "init", - "--user", - "--keg", "newstudy", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "init should succeed") - - // The keg directory exists on the local hub under @local/. - newstudyKeg := sb.MustReadFile("~/kegs/@local/newstudy/keg") - require.Contains(innerT, string(newstudyKeg), "$schema=", - "init should have written the keg under the local hub") - - // Now display the tap config; it should still render the user config - // (hubs + defaultKeg) and remain unmodified by init. - configCmd := NewProcess(innerT, false, "config") - configRes := configCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, configRes.Err, "config should succeed after init") - - stdout := string(configRes.Stdout) - require.Contains(innerT, stdout, "hubs:", "output should contain hubs section") - require.Contains(innerT, stdout, "defaultKeg:", "output should contain defaultKeg") - }) + sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) + res := NewProcess(t, false, "init").Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + require.Contains(t, string(res.Stderr), `unknown command "init"`) } func TestConfigCommand_ReadsExplicitConfigPath(t *testing.T) { @@ -234,33 +205,19 @@ func TestConfigCommand_ExplainFlagWithEnvVar(t *testing.T) { func TestConfigCommand_ProjectFlightPrecedenceMatchesOrient(t *testing.T) { t.Parallel() - sb := NewSandbox(t) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) project := "/home/testuser/work/project" descendant := project + "/src/pkg" + require.NoError(t, sb.Runtime().Mkdir(descendant, 0o755, true)) require.NoError(t, sb.Setwd(descendant)) + userConfig := "/home/testuser/.config/tapper/config.yaml" + remoteConfig, err := sb.Runtime().ReadFile(userConfig) + require.NoError(t, err) require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/.config/tapper/config.yaml", - []byte(`flight: +baseline -fallbackNamespace: local -hubs: - home: - kind: local - basePath: /home/testuser/kegs -`), 0o644)) + userConfig, append([]byte("flight: +baseline\n"), remoteConfig...), 0o644)) require.NoError(t, sb.Runtime().AtomicWriteFile( project+"/.tapper/config.yaml", []byte("flight: +project\n"), 0o644)) - for slug, instructions := range map[string]string{ - "baseline": "Baseline instructions", - "project": "Project instructions", - "environment": "Environment instructions", - "explicit": "Explicit instructions", - } { - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/"+slug+".yaml", - []byte("title: "+slug+"\ninstructions: "+instructions+"\n"), 0o644)) - } - explained := NewProcess(t, false, "config", "--explain", "flight").Run(sb.Context(), sb.Runtime()) require.NoError(t, explained.Err) require.Contains(t, string(explained.Stdout), "flight = +project") diff --git a/pkg/cli/cmd_create_test.go b/pkg/cli/cmd_create_test.go index 766812e7..4fce19e0 100644 --- a/pkg/cli/cmd_create_test.go +++ b/pkg/cli/cmd_create_test.go @@ -67,7 +67,7 @@ func TestCreate_Table(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - // Set up a fresh fixture per case so filesystem state is isolated. + // Set up a fresh fixture per case so repository state is isolated. fx := NewSandbox(t, testutils.WithFixture("testuser", "/home/testuser")) h := NewProcess(t, false, tc.args...) @@ -88,22 +88,19 @@ func TestCreate_Table(t *testing.T) { } // Verify README expectations. - readmePath := "~/kegs/@local/example/1/README.md" if tc.wantReadmeNotEmpty || len(tc.readmeContains) > 0 { - content := fx.MustReadFile(readmePath) + content := fixtureContent(t, fx.Runtime(), "example", "1") if tc.wantReadmeNotEmpty { require.NotEmpty(t, content, "expected README to be written for created node") } for _, want := range tc.readmeContains { - require.Contains(t, string(content), want) + require.Contains(t, content, want) } } // Verify meta expectations. - metaPath := "~/kegs/@local/example/1/meta.yaml" if len(tc.metaContains) > 0 { - meta := fx.MustReadFile(metaPath) - ms := string(meta) + ms := fixtureMeta(t, fx.Runtime(), "example", "1") for _, want := range tc.metaContains { if strings.Contains(want, "{now}") { want = strings.ReplaceAll(want, "{now}", now) @@ -113,10 +110,8 @@ func TestCreate_Table(t *testing.T) { } // Verify stats expectations. - statsPath := "~/kegs/@local/example/1/stats.json" if len(tc.statsContains) > 0 { - stats := fx.MustReadFile(statsPath) - ss := string(stats) + ss := fixtureStatsJSON(t, fx.Runtime(), "example", "1") for _, want := range tc.statsContains { if strings.Contains(want, "{now}") { want = strings.ReplaceAll(want, "{now}", now) @@ -150,7 +145,6 @@ func TestCreate_FromStdin(t *testing.T) { require.Regexp(t, `^\d+`, out) // Verify the created README contains the stdin content. - readmePath := "~/kegs/@local/example/1/README.md" - content := fx.MustReadFile(readmePath) - require.Contains(t, string(content), "This content came from stdin.") + content := fixtureContent(t, fx.Runtime(), "example", "1") + require.Contains(t, content, "This content came from stdin.") } diff --git a/pkg/cli/cmd_doctor.go b/pkg/cli/cmd_doctor.go index d30bf371..eb4eb994 100644 --- a/pkg/cli/cmd_doctor.go +++ b/pkg/cli/cmd_doctor.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -var tagMissingRE = regexp.MustCompile(`^tag "(.+)" not documented in keg config$`) +var tagMissingRE = regexp.MustCompile(`^tag "(.+)" not documented in keg settings$`) func NewDoctorCmd(deps *Deps) *cobra.Command { var opts tapper.DoctorOptions diff --git a/pkg/cli/cmd_edit_test.go b/pkg/cli/cmd_edit_test.go index 8625f6ba..0c278814 100644 --- a/pkg/cli/cmd_edit_test.go +++ b/pkg/cli/cmd_edit_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "encoding/json" "os" "path/filepath" "strings" @@ -42,8 +41,8 @@ EOF res := NewProcess(t, false, "edit", "0", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - content := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") + content := fixtureContent(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "tags:") require.Contains(t, meta, "- edited") require.Contains(t, meta, "summary: updated in editor") @@ -67,8 +66,8 @@ summary: from stdin res := NewProcess(t, false, "edit", "0", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), stdin) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - content := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") + content := fixtureContent(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: from stdin") require.Contains(t, meta, "- piped") require.Contains(t, content, "# Piped Body") @@ -83,7 +82,7 @@ func TestEdit_PipedSchemaSelectionPersistsType(t *testing.T) { res := NewProcess(t, false, "edit", "1", "--keg", "personal", "--schema", "task"). RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("# Edited with schema\n")) require.NoError(t, res.Err) - require.Contains(t, string(sb.MustReadFile("~/kegs/@local/personal/1/meta.yaml")), "type: task") + require.Contains(t, fixtureMeta(t, sb.Runtime(), "personal", "1"), "type: task") } func TestEdit_RejectsInvalidPipedFrontmatter(t *testing.T) { @@ -92,8 +91,8 @@ func TestEdit_RejectsInvalidPipedFrontmatter(t *testing.T) { require.NoError(t, sb.Runtime().Set("EDITOR", "/bin/false")) sb.Runtime().Unset("VISUAL") - beforeMeta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - beforeContent := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + beforeMeta := fixtureMeta(t, sb.Runtime(), "personal", "0") + beforeContent := fixtureContent(t, sb.Runtime(), "personal", "0") stdin := strings.NewReader(`--- tags: [ @@ -104,8 +103,8 @@ tags: [ require.Error(t, res.Err) require.Contains(t, string(res.Stderr), "invalid frontmatter yaml") - afterMeta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - afterContent := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + afterMeta := fixtureMeta(t, sb.Runtime(), "personal", "0") + afterContent := fixtureContent(t, sb.Runtime(), "personal", "0") require.Equal(t, beforeMeta, afterMeta) require.Equal(t, beforeContent, afterContent) } @@ -149,8 +148,8 @@ EOF res := NewProcess(t, false, "edit", "0", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) - content := string(sb.MustReadFile("~/kegs/@local/personal/0/README.md")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") + content := fixtureContent(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: first valid save") require.Contains(t, meta, "- live") require.Contains(t, content, "# Saved First") @@ -184,9 +183,8 @@ func TestEdit_InteractiveEdit_BumpsAccessCount(t *testing.T) { RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - var stats catStatsJSON - require.NoError(t, json.Unmarshal(sb.MustReadFile(statsPath), &stats)) - require.Equal(t, 4, stats.AccessCount, + stats := fixtureStats(t, sb.Runtime(), "personal", "0") + require.Equal(t, 4, stats.AccessCount(), "interactive edit should bump access_count") } @@ -211,8 +209,7 @@ tags: RunWithIO(sb.Context(), sb.Runtime(), stdin) require.NoError(t, res.Err) - var stats catStatsJSON - require.NoError(t, json.Unmarshal(sb.MustReadFile(statsPath), &stats)) - require.Equal(t, 3, stats.AccessCount, + stats := fixtureStats(t, sb.Runtime(), "personal", "0") + require.Equal(t, 3, stats.AccessCount(), "piped edit should not bump access_count") } diff --git a/pkg/cli/cmd_file_test.go b/pkg/cli/cmd_file_test.go index 68d8e313..042e5f4b 100644 --- a/pkg/cli/cmd_file_test.go +++ b/pkg/cli/cmd_file_test.go @@ -37,7 +37,7 @@ func TestFileUpload_StoresInAssetsDir(t *testing.T) { require.NoError(t, res.Err) // Confirm the file landed in assets/ (not attachments/). - uploaded := sb.MustReadFile("~/kegs/@local/example/0/assets/default.png") + uploaded := fixtureFile(t, sb.Runtime(), "example", "0", "default.png", false) require.NotEmpty(t, uploaded) } @@ -50,7 +50,7 @@ func TestFileUpload_CustomName(t *testing.T) { require.NoError(t, res.Err) require.Equal(t, "renamed.png", strings.TrimSpace(string(res.Stdout))) - uploaded := sb.MustReadFile("~/kegs/@local/example/0/assets/renamed.png") + uploaded := fixtureFile(t, sb.Runtime(), "example", "0", "renamed.png", false) require.NotEmpty(t, uploaded) } @@ -63,7 +63,7 @@ func TestFileUpload_ContentsPreserved(t *testing.T) { NewProcess(t, false, "file", "upload", "0", "~/test-images/default.png"). Run(sb.Context(), sb.Runtime()) - stored := sb.MustReadFile("~/kegs/@local/example/0/assets/default.png") + stored := fixtureFile(t, sb.Runtime(), "example", "0", "default.png", false) require.Equal(t, original, stored) } @@ -186,7 +186,7 @@ func TestFile_ErrorCases(t *testing.T) { { name: "ls_missing_node", args: []string{"file", "ls", "999"}, - wantErrFrag: "999", + wantErrFrag: "file does not exist", }, } diff --git a/pkg/cli/cmd_flight.go b/pkg/cli/cmd_flight.go index 93a91cd3..9f6431cd 100644 --- a/pkg/cli/cmd_flight.go +++ b/pkg/cli/cmd_flight.go @@ -156,9 +156,14 @@ func newFlightEditCmd(deps *Deps) *cobra.Command { Long: `Opens the flight manifest (title, visibility, capabilities, cover, instructions) as YAML in the configured editor with a yaml-language-server schema modeline; every save is applied to the hub. Piped stdin applies a full manifest without opening an editor.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + current, err := deps.Tap.GetFlight(cmd.Context(), tapper.GetFlightOptions{Name: args[0]}) + if err != nil { + return err + } flight, err := deps.Tap.EditFlight(cmd.Context(), tapper.EditFlightOptions{ - Ref: args[0], - Stream: deps.Runtime.Stream(), + Ref: args[0], + ExpectedHash: current.ManifestHash, + Stream: deps.Runtime.Stream(), }) if err != nil { return err @@ -177,7 +182,11 @@ func newFlightDeleteCmd(deps *Deps) *cobra.Command { Short: "delete a Hub-backed flight", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return deps.Tap.DeleteFlight(cmd.Context(), tapper.DeleteFlightOptions{Ref: args[0]}) + current, err := deps.Tap.GetFlight(cmd.Context(), tapper.GetFlightOptions{Name: args[0]}) + if err != nil { + return err + } + return deps.Tap.DeleteFlight(cmd.Context(), tapper.DeleteFlightOptions{Ref: args[0], ExpectedHash: current.ManifestHash}) }, } cmd.ValidArgsFunction = flightArgCompletionFunc(deps) diff --git a/pkg/cli/cmd_flight_test.go b/pkg/cli/cmd_flight_test.go index 84c228d1..824962df 100644 --- a/pkg/cli/cmd_flight_test.go +++ b/pkg/cli/cmd_flight_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" + "github.com/jlrickert/tapper/pkg/schemas" "github.com/jlrickert/tapper/pkg/tapper" "github.com/stretchr/testify/require" ) @@ -111,7 +112,8 @@ func TestFlightEdit_EditorStartsWithSchemaManifest(t *testing.T) { require.NoError(t, err) require.True(t, strings.HasPrefix(strings.TrimSpace(string(basenameRaw)), "tap-flight-edit-foldwise-agent-work-")) opened := string(raw) - require.True(t, strings.HasPrefix(opened, "# yaml-language-server: $schema="+tapper.FlightManifestSchemaURL+"\n")) + require.True(t, strings.HasPrefix(opened, + schemas.ModelinePrefix+schemas.ModelineURI(sb.Runtime(), schemas.FlightManifest)+"\n"), "got: %s", opened) require.Contains(t, opened, `title: ""`) require.Contains(t, opened, `visibility: private`) require.Contains(t, opened, `capabilities: []`) diff --git a/pkg/cli/cmd_graph.go b/pkg/cli/cmd_graph.go deleted file mode 100644 index 45f6c161..00000000 --- a/pkg/cli/cmd_graph.go +++ /dev/null @@ -1,67 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/spf13/cobra" -) - -// NewGraphCmd returns the `graph` cobra command. -// -// Usage examples: -// -// tap graph -// tap graph --keg pub --output graph.html -func NewGraphCmd(deps *Deps) *cobra.Command { - var ( - opts tapper.GraphOptions - outputPath string - ) - - cmd := &cobra.Command{ - Use: "graph", - Short: "render an interactive keg graph as self-contained HTML", - Long: `Render KEG nodes and relationships as a standalone HTML page. - -The output includes both forward links and backlinks, and can be sent to stdout -or written to a file with --output.`, - RunE: func(cmd *cobra.Command, args []string) error { - applyKegTargetProfile(deps, &opts.KegTargetOptions) - opts.BundleJS = graphBundle - - html, err := deps.Tap.Graph(cmd.Context(), opts) - if err != nil { - return err - } - - if strings.TrimSpace(outputPath) == "" { - _, err = fmt.Fprint(cmd.OutOrStdout(), html) - return err - } - - path := toolkit.ExpandEnv(deps.Runtime, outputPath) - path, err = toolkit.ExpandPath(deps.Runtime, path) - if err != nil { - return fmt.Errorf("unable to resolve output path %q: %w", outputPath, err) - } - dir := filepath.Dir(path) - if err := deps.Runtime.Mkdir(dir, 0o755, true); err != nil { - return fmt.Errorf("unable to create output directory %q: %w", dir, err) - } - if err := deps.Runtime.AtomicWriteFile(path, []byte(html), 0o644); err != nil { - return fmt.Errorf("unable to write output file %q: %w", path, err) - } - - _, err = fmt.Fprintf(cmd.OutOrStdout(), "graph written to %s\n", path) - return err - }, - } - - cmd.Flags().StringVarP(&outputPath, "output", "o", "", "write graph HTML to file (default: stdout)") - - return cmd -} diff --git a/pkg/cli/cmd_graph_test.go b/pkg/cli/cmd_graph_test.go deleted file mode 100644 index af17e7fd..00000000 --- a/pkg/cli/cmd_graph_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "testing" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/stretchr/testify/require" -) - -type graphPayload struct { - Nodes []graphNode `json:"nodes"` - Edges []graphEdge `json:"edges"` -} - -type graphNode struct { - ID string `json:"id"` - Label string `json:"label"` - Summary string `json:"summary"` - Tags []string `json:"tags"` - URL string `json:"url"` -} - -type graphEdge struct { - Source string `json:"source"` - Target string `json:"target"` - Type string `json:"type"` -} - -func TestGraphCommand_NoConfiguredKegErrors(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - res := NewProcess(t, false, "graph").Run(sb.Context(), sb.Runtime()) - require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "tap bootstrap") -} - -func TestGraphCommand_OutputsSelfContainedHTML(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - - createOne := `--- -tags: - - alpha ---- -# Alpha Node - -Alpha lead paragraph. -` - res := NewProcess(t, true, "create", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader(createOne)) - require.NoError(t, res.Err) - idOne := strings.TrimSpace(string(res.Stdout)) - require.NotEmpty(t, idOne) - - createTwo := fmt.Sprintf(`--- -tags: - - beta ---- -# Beta Node - -Beta lead paragraph with [alpha](../%s). -`, idOne) - res = NewProcess(t, true, "create", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader(createTwo)) - require.NoError(t, res.Err) - idTwo := strings.TrimSpace(string(res.Stdout)) - require.NotEmpty(t, idTwo) - - res = NewProcess(t, false, "index", "rebuild", "--keg", "personal").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - res = NewProcess(t, false, "graph", "--keg", "personal").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - html := string(res.Stdout) - require.Contains(t, html, "") - require.Contains(t, html, "window.__KEG__ = ") - - payload := mustGraphPayload(t, html) - require.NotEmpty(t, payload.Nodes) - require.NotEmpty(t, payload.Edges) - - require.True(t, containsGraphNode(payload.Nodes, graphNode{ - ID: idOne, - Label: "Alpha Node", - Summary: "Alpha lead paragraph.", - }), "expected node 1 to include title and lead paragraph") - require.True(t, containsGraphEdge(payload.Edges, graphEdge{ - Source: idTwo, - Target: idOne, - Type: "link", - }), "expected forward link edge 2 -> 1") - require.True(t, containsGraphEdge(payload.Edges, graphEdge{ - Source: idOne, - Target: idTwo, - Type: "backlink", - }), "expected backlink edge 1 -> 2") -} - -func TestGraphCommand_WritesOutputFile(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - res := NewProcess(t, false, "graph", "--keg", "personal", "--output", "~/graph.html").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "graph written to") - - raw := sb.MustReadFile("~/graph.html") - out := string(raw) - require.Contains(t, out, "") - require.Contains(t, out, "window.__KEG__ = ") -} - -func TestKegGraphCommand_WorksOnProjectKeg(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - initRes := NewProcess(t, false, - "init", "--project", "--cwd", "--keg", "project", "--creator", "test-user", - ).Run(sb.Context(), sb.Runtime()) - require.NoError(t, initRes.Err) - - res := NewKegProcess(t, false, "graph").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "") - require.Contains(t, string(res.Stdout), "window.__KEG__ = ") -} - -func mustGraphPayload(t *testing.T, html string) graphPayload { - t.Helper() - - re := regexp.MustCompile(`(?s)window\.__KEG__\s*=\s*(\{.*\});\s*`) - matches := re.FindStringSubmatch(html) - require.Len(t, matches, 2, "expected embedded graph JSON in HTML") - - var payload graphPayload - require.NoError(t, json.Unmarshal([]byte(matches[1]), &payload)) - return payload -} - -func containsGraphNode(nodes []graphNode, want graphNode) bool { - for _, node := range nodes { - if node.ID != want.ID { - continue - } - if node.Label != want.Label { - continue - } - if node.Summary != want.Summary { - continue - } - return true - } - return false -} - -func containsGraphEdge(edges []graphEdge, want graphEdge) bool { - for _, edge := range edges { - if edge.Source == want.Source && edge.Target == want.Target && edge.Type == want.Type { - return true - } - } - return false -} diff --git a/pkg/cli/cmd_hook.go b/pkg/cli/cmd_hook.go index 0fc02381..8fb69a51 100644 --- a/pkg/cli/cmd_hook.go +++ b/pkg/cli/cmd_hook.go @@ -18,12 +18,27 @@ const skipRootInitializationAnnotation = "tapper.io/skip-root-initialization" const hookOrientationReminder = "Before KEG work, call `mcp__tapper__orient` and follow the returned flight and KEG instructions. If that tool is unavailable, report that the Tapper MCP connection is unavailable, ask the user to reconnect or restart the host session, and never kill host-owned processes. Continue using only `mcp__tapper__*` tools for KEG work; never read or write tapper node storage files directly. Snapshot before meaningful node edits with `mcp__tapper__node_snapshot`. For cross-keg work, pass the `keg` parameter instead of changing directories or restarting the MCP server. Direct `tap` / `keg` CLI use from Codex remains blocked except help, version, and completion probes." -const hookDenyReason = "Direct tap/keg CLI invocations are blocked for the agent. Use the mcp__tapper__* tools instead. See integrations/content/agent-orient.md (the 'never read or write node files directly' policy). Allowlisted: 'tap completion', '--version', '--help'." +const hookDenyReason = "Tapper's agent guard blocked a recognized direct configuration mutation or prohibited tap/keg invocation. Use capability-authorized mcp__tapper__* operations; reads remain allowed. Allowlisted CLI probes: completion, --version, --help." var ( - hookAllowlist = map[string]bool{"completion": true, "--version": true, "-v": true, "--help": true, "-h": true} - hookWrappers = map[string]bool{"sudo": true, "command": true, "exec": true, "builtin": true, "time": true} - hookShells = map[string]bool{"bash": true, "sh": true, "zsh": true, "dash": true} + hookAllowlist = map[string]bool{"completion": true, "--version": true, "-v": true, "--help": true, "-h": true} + hookWrappers = map[string]bool{"sudo": true, "command": true, "exec": true, "builtin": true, "time": true} + hookShells = map[string]bool{"bash": true, "sh": true, "zsh": true, "dash": true} + hookShellTools = map[string]bool{ + "bash": true, "shell": true, "exec_command": true, "execute": true, "terminal": true, + } + hookFilesystemTools = map[string]bool{ + "write": true, "edit": true, "multiedit": true, "notebookedit": true, + "apply_patch": true, "write_file": true, "edit_file": true, "delete_file": true, + "move_file": true, "rename_file": true, + } + hookMutators = map[string]bool{ + "apply_patch": true, "chmod": true, "chown": true, "cp": true, + "ed": true, "emacs": true, "install": true, "ln": true, "mkdir": true, + "mv": true, "nano": true, "perl": true, "rm": true, "rmdir": true, + "sed": true, "tee": true, "touch": true, "truncate": true, "vi": true, + "vim": true, + } ) type hookInputError struct{ message string } @@ -120,8 +135,8 @@ func runPreToolUseHook(rt *toolkit.Runtime) error { if !ok { return nil } - command, ok := hookStringField(toolInput, "command") - if !ok || command == "" || !hookCommandDenied(command, 0) { + toolName, _ := hookStringField(payload, "tool_name") + if !hookToolInputDenied(rt, toolName, toolInput) { return nil } out := struct { @@ -184,12 +199,18 @@ func writeHookJSON(w io.Writer, value any) error { return enc.Encode(value) } -func hookCommandDenied(command string, depth int) bool { +func hookCommandDeniedWithRuntime(rt *toolkit.Runtime, command string, depth int) bool { + if hookPatchTargetsProtectedPath(rt, command) { + return true + } for _, segment := range splitHookSegments(command) { argv, err := splitHookWords(strings.TrimSpace(segment)) if err != nil || len(argv) == 0 { continue } + if hookReservedEnvironmentChange(argv) || hookRedirectionTargetsProtectedPath(rt, argv) { + return true + } argv = stripHookAssignments(argv) if len(argv) == 0 { continue @@ -202,15 +223,179 @@ func hookCommandDenied(command string, depth int) bool { } base := normalizeHookCommand(argv[0]) if hookShells[base] && len(argv) >= 3 && argv[1] == "-c" && depth < 1 { - if hookCommandDenied(argv[2], depth+1) { + if hookCommandDeniedWithRuntime(rt, argv[2], depth+1) { return true } continue } - if base != "tap" && base != "keg" { + if base == "tap" || base == "keg" { + if len(argv) < 2 || !hookAllowlist[argv[1]] { + return true + } + continue + } + if base == "apply_patch" { + for _, body := range argv[1:] { + if hookPatchTargetsProtectedPath(rt, body) { + return true + } + } + } + if hookMutators[base] && hookMutationTargetsProtectedPath(rt, base, argv[1:]) { + return true + } + } + return false +} + +func hookToolInputDenied(rt *toolkit.Runtime, toolName string, input map[string]json.RawMessage) bool { + lowerName := strings.ToLower(strings.TrimSpace(toolName)) + if strings.HasPrefix(lowerName, "mcp__tapper__") { + return false + } + if hookShellTools[lowerName] { + command, ok := hookStringField(input, "command") + return ok && hookCommandDeniedWithRuntime(rt, command, 0) + } + if !hookFilesystemTools[lowerName] { + return false + } + for key, raw := range input { + key = strings.ToLower(key) + if strings.Contains(key, "path") || strings.Contains(key, "file") || strings.Contains(key, "target") || strings.Contains(key, "destination") { + var value string + if json.Unmarshal(raw, &value) == nil && hookProtectedPath(rt, value) { + return true + } + } + if key == "patch" || key == "content" { + var value string + if json.Unmarshal(raw, &value) == nil && hookPatchTargetsProtectedPath(rt, value) { + return true + } + } + } + return false +} + +func hookReservedEnvironmentChange(argv []string) bool { + for i, arg := range argv { + if argv[0] == "env" && (arg == "-u" || arg == "--unset") && i+1 < len(argv) && + (argv[i+1] == "TAP_FLIGHT" || argv[i+1] == "TAP_AGENT") { + return true + } + name := strings.TrimSpace(strings.TrimPrefix(arg, "--unset=")) + if assignment, _, ok := strings.Cut(name, "="); ok { + name = assignment + } + name = strings.TrimPrefix(name, "export ") + if name != "TAP_FLIGHT" && name != "TAP_AGENT" { continue } - if len(argv) < 2 || !hookAllowlist[argv[1]] { + if i == 0 || isHookAssignment(arg) || argv[0] == "export" || argv[0] == "unset" || argv[0] == "env" { + return true + } + } + return false +} + +func hookRedirectionTargetsProtectedPath(rt *toolkit.Runtime, argv []string) bool { + for i, arg := range argv { + if arg == ">" || arg == ">>" || arg == "1>" || arg == "1>>" || arg == "2>" || arg == "2>>" { + if i+1 < len(argv) && hookProtectedPath(rt, argv[i+1]) { + return true + } + continue + } + if idx := strings.Index(arg, ">"); idx >= 0 && hookProtectedPath(rt, strings.TrimLeft(arg[idx:], ">")) { + return true + } + } + return false +} + +func hookMutationTargetsProtectedPath(rt *toolkit.Runtime, command string, args []string) bool { + paths := make([]string, 0, len(args)) + for _, arg := range args { + if arg == "" || arg == "-" || strings.HasPrefix(arg, "-") { + continue + } + paths = append(paths, arg) + } + switch command { + case "cp", "install", "ln": + if len(paths) > 0 { + paths = paths[len(paths)-1:] + } + case "sed": + if !hasHookInPlaceFlag(args) { + return false + } + case "perl": + if !hasHookInPlaceFlag(args) { + return false + } + } + for _, candidate := range paths { + if hookProtectedPath(rt, candidate) { + return true + } + } + return false +} + +func hasHookInPlaceFlag(args []string) bool { + for _, arg := range args { + if arg == "-i" || strings.HasPrefix(arg, "-i") || arg == "--in-place" || strings.HasPrefix(arg, "--in-place=") { + return true + } + if strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && strings.Contains(strings.TrimPrefix(arg, "-"), "i") { + return true + } + } + return false +} + +func hookPatchTargetsProtectedPath(rt *toolkit.Runtime, body string) bool { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + for _, prefix := range []string{"*** Add File:", "*** Update File:", "*** Delete File:", "+++ ", "--- ", "rename to ", "rename from "} { + if !strings.HasPrefix(line, prefix) { + continue + } + candidate := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if fields := strings.Fields(candidate); len(fields) > 0 { + candidate = fields[0] + } + if hookProtectedPath(rt, candidate) { + return true + } + } + } + return false +} + +func hookProtectedPath(rt *toolkit.Runtime, raw string) bool { + raw = strings.TrimSpace(strings.Trim(raw, "'\"")) + if raw == "" || raw == "/dev/null" { + return false + } + candidates := []string{raw} + if rt != nil { + if resolved, err := rt.ResolvePath(raw, false); err == nil { + candidates = append(candidates, resolved) + } + // Following the final component catches an alias that already points at + // config.yaml. ResolvePath(false) above still covers a not-yet-created + // atomic-rename destination. + if resolved, err := rt.ResolvePath(raw, true); err == nil { + candidates = append(candidates, resolved) + } + } + for _, candidate := range candidates { + clean := filepath.ToSlash(filepath.Clean(candidate)) + if clean == ".tapper/config.yaml" || clean == "tapper/config.yaml" || + strings.HasSuffix(clean, "/.tapper/config.yaml") || strings.HasSuffix(clean, "/tapper/config.yaml") { return true } } diff --git a/pkg/cli/cmd_hook_test.go b/pkg/cli/cmd_hook_test.go index 0065aae1..39705140 100644 --- a/pkg/cli/cmd_hook_test.go +++ b/pkg/cli/cmd_hook_test.go @@ -40,6 +40,18 @@ func TestHookPreToolUse_GuardsCommands(t *testing.T) { {name: "boolean pipeline", command: "printf ok && keg list", deny: true}, {name: "nested shell", command: `sh -c 'tap list | head'`, deny: true}, {name: "double quoted nested shell", command: `bash -c "keg cat 1"`, deny: true}, + {name: "reserved flight assignment", command: "TAP_FLIGHT=@team/+other codex", deny: true}, + {name: "reserved agent export", command: "export TAP_AGENT=other", deny: true}, + {name: "reserved flight unset", command: "unset TAP_FLIGHT", deny: true}, + {name: "reserved flight env unset", command: "env --unset TAP_FLIGHT codex", deny: true}, + {name: "user config redirect", command: "printf x > ~/.config/tapper/config.yaml", deny: true}, + {name: "project config write", command: "touch .tapper/config.yaml", deny: true}, + {name: "obsolete local flight manifest write", command: "cp next.yaml /tmp/kegs/flights.d/dev.yaml", deny: false}, + {name: "obsolete local flight manifest rename", command: "mv /tmp/kegs/flights.d/dev.yaml /tmp/dev.yaml", deny: false}, + {name: "flight patch", command: "apply_patch '*** Update File: .tapper/config.yaml'", deny: true}, + {name: "anchored patch only", command: "printf 'example *** Update File: .tapper/config.yaml'", deny: false}, + {name: "sed in place", command: "sed -i.bak s/x/y/ .tapper/config.yaml", deny: true}, + {name: "sed read only", command: "sed -n 1,2p .tapper/config.yaml", deny: false}, {name: "help long", command: "tap --help", deny: false}, {name: "help short", command: "keg -h", deny: false}, {name: "version long", command: "tap --version", deny: false}, @@ -47,13 +59,16 @@ func TestHookPreToolUse_GuardsCommands(t *testing.T) { {name: "completion", command: "tap completion zsh", deny: false}, {name: "quoted command text", command: `echo "tap list && keg cat 1"`, deny: false}, {name: "substring", command: "taproom list", deny: false}, + {name: "config read", command: "cat ~/.config/tapper/config.yaml", deny: false}, + {name: "flight read", command: "rg title /tmp/kegs/flights.d/dev.yaml", deny: false}, + {name: "copy config out is read", command: "cp ~/.config/tapper/config.yaml /tmp/config-copy.yaml", deny: false}, {name: "lowercase assignment is not shell env prefix", command: "foo=bar tap list", deny: false}, {name: "unbalanced quote fails open", command: `echo 'tap list`, deny: false}, {name: "shell recursion is one level", command: `sh -c "sh -c 'tap list'"`, deny: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.deny, hookCommandDenied(tc.command, 0)) + require.Equal(t, tc.deny, hookCommandDeniedWithRuntime(nil, tc.command, 0)) }) } } @@ -66,7 +81,13 @@ func TestHookPreToolUse_Protocol(t *testing.T) { exitCode int deny bool }{ - {name: "deny", input: `{"hook_event_name":"PreToolUse","tool_input":{"command":"tap list"}}`, deny: true}, + {name: "deny", input: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"tap list"}}`, deny: true}, + {name: "deny direct write", input: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/home/testuser/.config/tapper/config.yaml","content":"flight: +other"}}`, deny: true}, + {name: "deny direct patch", input: `{"hook_event_name":"PreToolUse","tool_name":"apply_patch","tool_input":{"patch":"*** Update File: .tapper/config.yaml\n@@"}}`, deny: true}, + {name: "allow direct read", input: `{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/home/testuser/.config/tapper/config.yaml"}}`}, + {name: "allow grep bypass", input: `{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"path":"/home/testuser/.config/tapper/config.yaml","pattern":"flight"}}`}, + {name: "allow glob bypass", input: `{"hook_event_name":"PreToolUse","tool_name":"Glob","tool_input":{"path":"/home/testuser/kegs/flights.d"}}`}, + {name: "allow tapper mcp diff content", input: `{"hook_event_name":"PreToolUse","tool_name":"mcp__tapper__edit","tool_input":{"keg":"@local/dev","content":"*** Update File: .tapper/config.yaml"}}`}, {name: "allow", input: `{"tool_input":{"command":"tap --help"}}`}, {name: "missing tool input", input: `{}`}, {name: "missing command", input: `{"tool_input":{}}`}, @@ -102,10 +123,26 @@ func TestHookPreToolUse_Protocol(t *testing.T) { require.NoError(t, json.Unmarshal(res.Stdout, &output)) require.Equal(t, "PreToolUse", output.HookSpecificOutput.HookEventName) require.Equal(t, "deny", output.HookSpecificOutput.PermissionDecision) + require.Contains(t, string(res.Stdout), "recognized direct configuration mutation") }) } } +func TestHookPreToolUse_ProtectsSymlinksAndAtomicRenames(t *testing.T) { + sb := newTestSandbox(t) + rt := sb.Runtime() + require.NoError(t, rt.Mkdir("/home/testuser/.tapper", 0o755, true)) + require.NoError(t, rt.WriteFile("/home/testuser/.tapper/config.yaml", []byte("flight: +root\n"), 0o644)) + require.NoError(t, rt.Symlink("/home/testuser/.tapper/config.yaml", "/home/testuser/config-link")) + + require.True(t, hookCommandDeniedWithRuntime(rt, "sed -i s/root/child/ /home/testuser/config-link", 0), + "a final-component symlink to protected configuration must be guarded") + require.True(t, hookCommandDeniedWithRuntime(rt, "mv /tmp/config.next /home/testuser/.tapper/config.yaml", 0), + "an atomic rename into protected configuration must be guarded") + require.False(t, hookCommandDeniedWithRuntime(rt, "cat /home/testuser/config-link", 0), + "reading through the same symlink remains allowed") +} + func TestHookSessionStart_EmitsOrientationForLifecycleSources(t *testing.T) { t.Parallel() for _, source := range []string{"startup", "resume", "clear", "compact"} { @@ -147,7 +184,7 @@ func TestHookSessionStart_FailsOpen(t *testing.T) { require.Contains(t, string(res.Stderr), "allowing session startup") } -func TestHookCommands_ProfileGateAndHidden(t *testing.T) { +func TestHookCommandsAreHiddenOnTap(t *testing.T) { t.Parallel() sb := newTestSandbox(t) tapRoot := NewRootCmd(&Deps{Profile: TapProfile(), Runtime: sb.Runtime()}) @@ -156,8 +193,6 @@ func TestHookCommands_ProfileGateAndHidden(t *testing.T) { require.True(t, hook.Hidden) require.True(t, commandNames(t, sb.Runtime(), TapProfile())["integrate"]) require.True(t, commandNames(t, sb.Runtime(), TapProfile())["hook"]) - require.False(t, commandNames(t, sb.Runtime(), KegProfile())["integrate"]) - require.False(t, commandNames(t, sb.Runtime(), KegProfile())["hook"]) } func TestHookCommands_BypassRootInitialization(t *testing.T) { diff --git a/pkg/cli/cmd_image_test.go b/pkg/cli/cmd_image_test.go index 3aed2262..052159f0 100644 --- a/pkg/cli/cmd_image_test.go +++ b/pkg/cli/cmd_image_test.go @@ -36,7 +36,7 @@ func TestImageUpload_StoresInImagesDir(t *testing.T) { Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - uploaded := sb.MustReadFile("~/kegs/@local/example/0/images/default.png") + uploaded := fixtureFile(t, sb.Runtime(), "example", "0", "default.png", true) require.NotEmpty(t, uploaded) } @@ -49,7 +49,7 @@ func TestImageUpload_CustomName(t *testing.T) { require.NoError(t, res.Err) require.Equal(t, "hero.png", strings.TrimSpace(string(res.Stdout))) - uploaded := sb.MustReadFile("~/kegs/@local/example/0/images/hero.png") + uploaded := fixtureFile(t, sb.Runtime(), "example", "0", "hero.png", true) require.NotEmpty(t, uploaded) } @@ -62,7 +62,7 @@ func TestImageUpload_ContentsPreserved(t *testing.T) { NewProcess(t, false, "image", "upload", "0", "~/test-images/default.png"). Run(sb.Context(), sb.Runtime()) - stored := sb.MustReadFile("~/kegs/@local/example/0/images/default.png") + stored := fixtureFile(t, sb.Runtime(), "example", "0", "default.png", true) require.Equal(t, original, stored) } @@ -184,7 +184,7 @@ func TestImage_ErrorCases(t *testing.T) { { name: "ls_missing_node", args: []string{"image", "ls", "999"}, - wantErrFrag: "999", + wantErrFrag: "file does not exist", }, } diff --git a/pkg/cli/cmd_import_test.go b/pkg/cli/cmd_import_test.go index db6a9a80..5968135e 100644 --- a/pkg/cli/cmd_import_test.go +++ b/pkg/cli/cmd_import_test.go @@ -31,14 +31,14 @@ func TestImportCmd_BasicCopyWithLinkRewrite(t *testing.T) { require.Contains(t, out, "imported 2 node(s)") // Work node 1 (was personal/1): ../2 is imported → stays ../2; ../3 not imported → keg:personal/3 - node1 := string(sb.MustReadFile("~/kegs/@local/work/1/README.md")) + node1 := fixtureContent(t, sb.Runtime(), "work", "1") require.Contains(t, node1, "# Personal Overview") require.Contains(t, node1, "../2", "link to imported node 2 should remain relative") require.Contains(t, node1, "keg:personal/3", "link to non-imported node 3 should be cross-keg") require.NotContains(t, node1, "../3", "bare ../3 must not remain") // Work node 2 (was personal/2): ../1 imported → ../1; ../3 not imported → keg:personal/3 - node2 := string(sb.MustReadFile("~/kegs/@local/work/2/README.md")) + node2 := fixtureContent(t, sb.Runtime(), "work", "2") require.Contains(t, node2, "# Project Alpha") require.Contains(t, node2, "keg:personal/3") } @@ -72,7 +72,7 @@ func TestImportCmd_AllNodesSkipsZero(t *testing.T) { // Node 0 from personal must NOT be present in work (work already has its own 0). // Work's node 0 content should be unchanged. - node0 := string(sb.MustReadFile("~/kegs/@local/work/0/README.md")) + node0 := fixtureContent(t, sb.Runtime(), "work", "0") require.Contains(t, node0, "Sorry, planned but not yet available") } @@ -102,7 +102,7 @@ func TestImportCmd_LeaveStubs(t *testing.T) { require.NoError(t, res.Err) // personal/1/README.md should now be a stub. - stub := string(sb.MustReadFile("~/kegs/@local/personal/1/README.md")) + stub := fixtureContent(t, sb.Runtime(), "personal", "1") require.Contains(t, stub, "Personal Overview") require.Contains(t, stub, "keg:work/") require.Contains(t, stub, "Moved to") diff --git a/pkg/cli/cmd_index_test.go b/pkg/cli/cmd_index_test.go deleted file mode 100644 index 3767287b..00000000 --- a/pkg/cli/cmd_index_test.go +++ /dev/null @@ -1,378 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "testing" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -type statsJSON struct { - Title string `json:"title"` - Hash string `json:"hash"` - Updated string `json:"updated"` - Created string `json:"created"` - Lead string `json:"lead"` - Links []string `json:"links"` -} - -func TestIndexCommand_ErrorHandling(t *testing.T) { - tests := []struct { - name string - args []string - setupFixture *string - expectedErr string - description string - }{ - { - name: "index_get_nonexistent_alias", - args: []string{"index", "get", "--keg", "nonexistent", "nodes.tsv"}, - setupFixture: strPtr("joe"), - expectedErr: "not found", - description: "Error when keg does not exist on disk", - }, - { - name: "index_list_not_bootstrapped", - args: []string{"index", "list"}, - expectedErr: "tap bootstrap", - description: "Error when tapper is not bootstrapped", - }, - { - name: "index_get_unknown_index", - args: []string{"index", "get", "--keg", "example", "does-not-exist.md"}, - setupFixture: strPtr("testuser"), - expectedErr: "not found", - description: "Error when named index does not exist", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(innerT *testing.T) { - innerT.Parallel() - var opts []testutils.Option - if tt.setupFixture != nil { - opts = append(opts, testutils.WithFixture(*tt.setupFixture, "~")) - } - sb := NewSandbox(innerT, opts...) - - h := NewProcess(innerT, false, tt.args...) - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err, "expected error - %s", tt.description) - stderr := string(res.Stderr) - require.Contains(innerT, stderr, tt.expectedErr, - "error message should contain %q, got stderr: %s and err: %v", tt.expectedErr, stderr, res.Err) - }) - } -} - -func TestIndexListCommand_ListIndexes(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - // Ensure dex artifacts exist first - rebuild := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := rebuild.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - h := NewProcess(t, false, "index", "list", "--keg", "example") - res = h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index list should succeed") - - stdout := string(res.Stdout) - require.Contains(t, stdout, "nodes.tsv") - require.Contains(t, stdout, "tags") - require.Contains(t, stdout, "timeline") - require.Contains(t, stdout, "dirty") -} - -func TestIndexGetCommand_CatNamedIndex(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - // Ensure dex artifacts exist first - rebuild := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := rebuild.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - h := NewProcess(t, false, "index", "get", "--keg", "example", "nodes.tsv") - res = h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index get should succeed") - - stdout := string(res.Stdout) - require.NotEmpty(t, stdout, "nodes.tsv should have content") - - for _, name := range []string{"timeline", "dirty"} { - h := NewProcess(t, false, "index", "get", "--keg", "example", name) - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index get %s should succeed", name) - } -} - -func TestIndexGetCommand_CompletionSuggestsIndexNames(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - // Ensure dex artifacts exist - rebuild := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := rebuild.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - comp := NewCompletionProcess(t, false, 0, "index", "get", "--keg", "example", "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - - suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "nodes.tsv") - require.Contains(t, suggestions, "tags") - require.Contains(t, suggestions, "timeline") - require.Contains(t, suggestions, "dirty") -} - -func TestIndexRebuildCommand_TableDrivenErrorHandling(t *testing.T) { - tests := []struct { - name string - args []string - setupFixture *string - expectedErr string - description string - }{ - { - name: "rebuild_nonexistent_alias", - args: []string{"index", "rebuild", "--keg", "nonexistent"}, - setupFixture: strPtr("joe"), - expectedErr: "keg not initialized", - description: "Error when keg does not exist on disk", - }, - { - name: "rebuild_not_bootstrapped", - args: []string{"index", "rebuild"}, - expectedErr: "tap bootstrap", - description: "Error when tapper is not bootstrapped", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(innerT *testing.T) { - innerT.Parallel() - var opts []testutils.Option - if tt.setupFixture != nil { - opts = append(opts, testutils.WithFixture(*tt.setupFixture, "~")) - } - sb := NewSandbox(innerT, opts...) - - h := NewProcess(innerT, false, tt.args...) - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err, "expected error - %s", tt.description) - stderr := string(res.Stderr) - require.Contains(innerT, stderr, tt.expectedErr, - "error message should contain %q, got stderr: %s and err: %v", tt.expectedErr, stderr, res.Err) - }) - } -} - -func TestIndexRebuildCommand_WithJoeFixture(t *testing.T) { - tests := []struct { - name string - args []string - setupFixture *string - cwd *string - expectedInStdout []string - description string - }{ - { - name: "rebuild_personal_keg_from_default_location", - args: []string{"index", "rebuild"}, - setupFixture: strPtr("joe"), - expectedInStdout: []string{"Indices rebuilt"}, - description: "Rebuild indices for default personal keg", - }, - { - name: "rebuild_work_keg_from_work_directory", - args: []string{"index", "rebuild"}, - setupFixture: strPtr("joe"), - cwd: strPtr("~/repos/work/spy-things"), - expectedInStdout: []string{"Indices rebuilt"}, - description: "Rebuild indices for work keg when in work directory", - }, - { - name: "rebuild_explicit_alias_overrides_path_resolution", - args: []string{"index", "rebuild", "--keg", "example"}, - setupFixture: strPtr("joe"), - cwd: strPtr("~/repos/work/spy-things"), - expectedInStdout: []string{"Indices rebuilt"}, - description: "Explicit alias overrides path-based keg resolution", - }, - { - name: "rebuild_personal_keg_explicit_alias", - args: []string{"index", "rebuild", "--keg", "personal"}, - setupFixture: strPtr("joe"), - expectedInStdout: []string{"Indices rebuilt"}, - description: "Rebuild indices for personal keg with explicit alias", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(innerT *testing.T) { - innerT.Parallel() - var opts []testutils.Option - if tt.setupFixture != nil { - opts = append(opts, testutils.WithFixture(*tt.setupFixture, "~")) - } - sb := NewSandbox(innerT, opts...) - - if tt.cwd != nil { - sb.Setwd(*tt.cwd) - } - - h := NewProcess(innerT, false, tt.args...) - res := h.Run(sb.Context(), sb.Runtime()) - - require.NoError(innerT, res.Err, "index rebuild command should succeed - %s", tt.description) - stdout := string(res.Stdout) - - for _, expected := range tt.expectedInStdout { - require.Contains(innerT, stdout, expected, - "expected output to contain %q, got:\n%s", expected, stdout) - } - }) - } -} - -func TestIndexRebuildCommand_IntegrationWithInit(t *testing.T) { - t.Run("rebuild_after_init", func(innerT *testing.T) { - innerT.Parallel() - opts := []testutils.Option{ - testutils.WithFixture("testuser", "~"), - } - sb := NewSandbox(innerT, opts...) - - initCmd := NewProcess(innerT, false, - "init", - "--user", - "--keg", "newstudy", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "init should succeed") - require.Contains(innerT, string(initRes.Stdout), "keg newstudy created") - - rebuildCmd := NewProcess(innerT, false, "index", "rebuild", "--keg", "newstudy") - rebuildRes := rebuildCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, rebuildRes.Err, "index rebuild should succeed") - - stdout := string(rebuildRes.Stdout) - require.Contains(innerT, stdout, "Indices rebuilt", "output should indicate successful rebuild") - }) -} - -func TestIndexRebuildCommand_CreatesMissingMetaAndStatsFiles(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - metaPath := "~/kegs/@local/example/0/meta.yaml" - statsPath := "~/kegs/@local/example/0/stats.json" - - require.NoError(t, sb.Runtime().Remove(metaPath, false)) - _ = sb.Runtime().Remove(statsPath, false) - - h := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index rebuild should repair missing node files") - - _, err := sb.Runtime().Stat(metaPath, false) - require.NoError(t, err, "meta.yaml should be recreated") - _, err = sb.Runtime().Stat(statsPath, false) - require.NoError(t, err, "stats.json should be recreated") - - statsRaw := sb.MustReadFile(statsPath) - var got statsJSON - require.NoError(t, json.Unmarshal(statsRaw, &got)) - require.NotEmpty(t, got.Title) - require.NotEmpty(t, got.Hash) - require.NotEmpty(t, got.Updated) - require.NotEmpty(t, got.Created) - require.NotEmpty(t, got.Lead) -} - -func TestIndexRebuildCommand_UpdatesStatsFromNodeContent(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - contentPath := "~/kegs/@local/example/0/README.md" - statsPath := "~/kegs/@local/example/0/stats.json" - oldUpdated := "2001-01-01T00:00:00Z" - oldCreated := "2001-01-01T00:00:00Z" - - bogus := []byte(`{"title":"WRONG","hash":"bad-hash","updated":"` + oldUpdated + `","created":"` + oldCreated + `","lead":"wrong lead","links":["9999"]}`) - sb.MustWriteFile(statsPath, bogus, 0o644) - - h := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index rebuild should refresh stale stats") - - contentRaw := sb.MustReadFile(contentPath) - parsed, err := keg.ParseContent(sb.Runtime(), contentRaw, keg.FormatMarkdown) - require.NoError(t, err) - - statsRaw := sb.MustReadFile(statsPath) - var got statsJSON - require.NoError(t, json.Unmarshal(statsRaw, &got)) - - require.Equal(t, parsed.Title, got.Title, "title should be derived from content") - require.NotEqual(t, parsed.Hash, got.Hash, "hash should include metadata state") - require.Equal(t, parsed.Lead, got.Lead, "lead should be derived from content") - require.NotEqual(t, oldUpdated, got.Updated, "updated timestamp should move forward") - require.Equal(t, oldCreated, got.Created, "created timestamp should be preserved") - require.Empty(t, got.Links, "links should reflect parsed content") - - rawKeg, err := keg.NewKegFromTarget(sb.Context(), keg.NewFile("~/kegs/@local/example"), sb.Runtime()) - require.NoError(t, err) - local, ok := rawKeg.(*keg.LocalKeg) - require.True(t, ok) - changed, err := local.Node(keg.NodeId{ID: 0}).Changed(sb.Context()) - require.NoError(t, err) - require.False(t, changed, "rebuilt stats should match current source state") -} - -func TestIndexRebuildCommand_CreatesDexArtifactsWhenMissing(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - dexDir := "~/kegs/@local/example/dex" - require.NoError(t, sb.Runtime().Remove(dexDir, true)) - - h := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "index rebuild should recreate dex artifacts") - - for _, path := range []string{ - "~/kegs/@local/example/dex/nodes.tsv", - "~/kegs/@local/example/dex/tags", - "~/kegs/@local/example/dex/links", - "~/kegs/@local/example/dex/backlinks", - "~/kegs/@local/example/dex/timeline", - "~/kegs/@local/example/dex/dirty", - } { - _, err := sb.Runtime().Stat(path, false) - require.NoError(t, err, "expected dex artifact to exist: %s", path) - } -} - -func TestIndexRebuildCommand_FailsOnMalformedMeta(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - - metaPath := "~/kegs/@local/example/0/meta.yaml" - sb.MustWriteFile(metaPath, []byte("title: [\n"), 0o644) - - h := NewProcess(t, false, "index", "rebuild", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(t, res.Err, "index rebuild should fail for malformed meta") - stderr := string(res.Stderr) - require.Contains(t, stderr, "unable to rebuild indices") - require.Contains(t, stderr, "failed to parse meta yaml") -} diff --git a/pkg/cli/cmd_init.go b/pkg/cli/cmd_init.go index 444f8429..abfaf22a 100644 --- a/pkg/cli/cmd_init.go +++ b/pkg/cli/cmd_init.go @@ -1,295 +1,89 @@ package cli import ( - "bufio" - "errors" "fmt" - "io" - "path/filepath" "strings" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" ) -// newKegCreateCmd returns the `tap keg create` cobra subcommand — the canonical -// keg-creation command (formerly `tap init`). -// -// Usage examples: -// -// tap keg create --keg blog -// tap keg create --project -// tap keg create --keg blog --cwd -// tap keg create --keg blog --hub knut --namespace me -// tap keg create --keg blog --path ./kegs/blog --title "Blog" --creator "me" +// newKegCreateCmd returns the hub-only `tap keg create` command. func newKegCreateCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "create [name | @namespace/name]", - Short: "create and initialize a new keg", - Long: strings.TrimSpace(` -Create a keg target and initialize it in one of three destinations: - -1. user (default) - Creates a filesystem-backed keg on the local hub at /@local/ - (the local hub's basePath, or the platform default when unset) and - writes/updates the alias in user config. - -2. local (--project, --cwd, or --path) - Creates a local filesystem-backed keg. By default this resolves to - /kegs/, - where is the git root when available. Use --cwd to base it on the - current working directory instead, or use --path to set an explicit - location. --path implies a local destination even when --project is not - passed. - -3. hub (--hub ) - Creates a hub/API keg target named and stores it in config without - creating local keg files. The hub name is required when --hub is used. - -Alias behavior: -- --keg sets the alias written to config and the directory name. -- If --keg is omitted, alias is inferred from the current working directory basename. - -Metadata: -- --title and --creator are written into the keg config for filesystem-backed kegs. - -Interactive mode: -- When stdin is a TTY and no destination/alias flags are provided, tap keg create - prompts for the alias, location category, title, and creator. Pass - --non-interactive to skip the prompt and rely on flag-driven defaults - (e.g. for CI or scripted invocations). -`), + Short: "create a new KEG on a configured hub", + Long: "Create a KEG through the configured Tapper Hub. Filesystem destinations are not supported.", Example: strings.TrimSpace(` -tap keg create --keg blog -tap keg create --project --cwd -tap keg create --keg blog --cwd -tap keg create --keg blog --path ./kegs/blog -tap keg create --keg blog --user -tap keg create --keg blog --hub knut --namespace me +tap keg create notes +tap keg create @acme/engineering --title "Engineering" +tap keg create notes --hub enterprise --namespace alice `), } configureKegCreateCmd(deps, cmd) return cmd } -// newInitCompatCmd is a hidden top-level alias preserving `tap init` for -// back-compat. `tap keg create` is the canonical, documented command. -func newInitCompatCmd(deps *Deps) *cobra.Command { - cmd := &cobra.Command{ - Use: "init [name | @namespace/name]", - Short: "create a new keg (deprecated alias for `keg create`)", - Hidden: true, - } - configureKegCreateCmd(deps, cmd) - return cmd -} - -// configureKegCreateCmd wires the shared keg-creation flags + RunE onto cmd so -// the canonical `keg create` and the hidden `init` alias behave identically. func configureKegCreateCmd(deps *Deps, cmd *cobra.Command) { - initOpts := tapper.InitOptions{} + options := tapper.InitOptions{} cmd.Args = cobra.MaximumNArgs(1) cmd.RunE = func(cmd *cobra.Command, args []string) error { - // The full `tap` surface requires `tap bootstrap` before a namespace/hub - // create; explicit local destinations stay exempt. - initOpts.RequireBootstrap = deps.Profile.withDefaults().IncludeConfigCommand - - // A positional argument names the keg, optionally namespace-qualified - // as "@namespace/name". An explicit --namespace flag still overrides - // the parsed namespace. + options.RequireBootstrap = deps.Profile.withDefaults().IncludeConfigCommand if len(args) == 1 { - ns, name, parseErr := parseKegArg(args[0]) - if parseErr != nil { - return parseErr - } - if name != "" { - initOpts.Keg = name + namespace, name, err := parseKegArg(args[0]) + if err != nil { + return err } - if ns != "" && strings.TrimSpace(initOpts.Namespace) == "" { - initOpts.Namespace = ns + options.Keg = name + if options.Namespace == "" { + options.Namespace = namespace } } - - // A non-local create needs configured hubs. Surface the bootstrap - // guidance up front rather than prompting for an alias/location and then - // failing. Explicit local destinations (--project/--cwd/--path) bypass. - if initOpts.RequireBootstrap && !initOpts.LocalDestination() && - deps.Tap != nil && !deps.Tap.ConfigService.UserConfigExists() { - return tapper.ErrNotBootstrapped - } - - if shouldPromptInit(deps, &initOpts) { - if err := promptInitOptions(cmd, deps, &initOpts); err != nil { - return err - } + if strings.TrimSpace(options.Keg) == "" { + return fmt.Errorf("KEG name is required") } - - if strings.TrimSpace(initOpts.Keg) == "" { - cwd, err := deps.Runtime.Getwd() - if err != nil { - return fmt.Errorf("unable to determine working directory for alias inference: %w", err) - } - initOpts.Keg = filepath.Base(cwd) + if options.RequireBootstrap && deps.Tap != nil && !deps.Tap.ConfigService.UserConfigExists() { + return tapper.ErrNotBootstrapped } - target, err := deps.Tap.InitKeg(cmd.Context(), initOpts) + target, err := deps.Tap.InitKeg(cmd.Context(), options) if err != nil { return err } - - // Report what was created and, crucially, where it landed — a bare - // "created" with no location is what made unconfigured creates feel like - // nothing happened. - msg := fmt.Sprintf("keg %s created", initOpts.Keg) + message := fmt.Sprintf("keg %s created", options.Keg) if label := tapper.KegBackendLabel(target); label != "" { - msg += fmt.Sprintf(" (%s)", label) + message += fmt.Sprintf(" (%s)", label) } - if loc := tapper.KegLocation(target); loc != "" { - msg += " " + loc + if location := tapper.KegLocation(target); location != "" { + message += " " + location } - _, err = fmt.Fprintln(cmd.OutOrStdout(), msg) + _, err = fmt.Fprintln(cmd.OutOrStdout(), message) return err } - cmd.Flags().BoolVar(&initOpts.Project, "project", false, "create a project-local keg") - cmd.Flags().BoolVar(&initOpts.User, "user", false, "create a user keg on the local hub at /@local/") - cmd.Flags().StringVar(&initOpts.Hub, "hub", "", "hub name (selects API-style hub target when set)") - cmd.Flags().BoolVar(&initOpts.Cwd, "cwd", false, "use cwd instead of git root for local destination resolution") - cmd.Flags().StringVar(&initOpts.Path, "path", "", "explicit local destination path; implies local mode") - cmd.Flags().StringVar(&initOpts.Namespace, "namespace", "", "namespace the keg belongs to (overrides @namespace/ and config resolution)") - cmd.Flags().StringVarP(&initOpts.Keg, "keg", "k", "", "alias of keg to add to config") - cmd.Flags().StringVar(&initOpts.Title, "title", "", "human title to write into the keg config") - cmd.Flags().StringVar(&initOpts.Creator, "creator", "", "creator identifier to include in the keg config") - cmd.Flags().StringVar(&initOpts.TokenEnv, "token-env", "", "environment variable name to store token reference (API targets)") - cmd.Flags().BoolVar(&initOpts.NonInteractive, "non-interactive", false, "skip the interactive prompt even when stdin is a TTY") + cmd.Flags().StringVar(&options.Hub, "hub", "", "configured hub name") + cmd.Flags().StringVar(&options.Namespace, "namespace", "", "namespace the KEG belongs to") + cmd.Flags().StringVarP(&options.Keg, "keg", "k", "", "KEG name") + cmd.Flags().StringVar(&options.Title, "title", "", "human-readable KEG title") + cmd.Flags().StringVar(&options.Visibility, "visibility", "", "KEG visibility: private or public") } -// parseKegArg splits an init positional argument into an optional namespace and -// a keg name. Forms: "name" → ("", "name"); "@namespace/name" → ("namespace", -// "name"). A bare "name" containing "/" (without the @ sigil) is rejected so a -// path-like typo doesn't silently become a keg name. func parseKegArg(arg string) (namespace, name string, err error) { arg = strings.TrimSpace(arg) if arg == "" { return "", "", nil } if strings.HasPrefix(arg, "@") { - ns, n, ok := strings.Cut(strings.TrimPrefix(arg, "@"), "/") - ns = strings.TrimSpace(ns) - n = strings.TrimSpace(n) - if !ok || ns == "" || n == "" { - return "", "", fmt.Errorf("invalid keg reference %q: expected @namespace/name", arg) + namespace, name, ok := strings.Cut(strings.TrimPrefix(arg, "@"), "/") + namespace = strings.TrimSpace(namespace) + name = strings.TrimSpace(name) + if !ok || namespace == "" || name == "" { + return "", "", fmt.Errorf("invalid KEG reference %q: expected @namespace/name", arg) } - return ns, n, nil + return namespace, name, nil } if strings.Contains(arg, "/") { - return "", "", fmt.Errorf("invalid keg name %q: use @namespace/name to qualify a namespace", arg) + return "", "", fmt.Errorf("invalid KEG name %q: use @namespace/name to qualify a namespace", arg) } return "", arg, nil } - -// shouldPromptInit reports whether the cobra RunE handler should fire the -// interactive keg-create prompt. The prompt is gated on three conditions: -// stdin is a TTY, --non-interactive is not set, and the user has supplied no -// destination flags or alias on the command line. Any explicit flag means the -// user has already declared their intent; only the bare invocation triggers the -// conversational path. -func shouldPromptInit(deps *Deps, opts *tapper.InitOptions) bool { - if deps == nil || deps.Runtime == nil { - return false - } - if !deps.Runtime.Stream().IsTTY { - return false - } - if opts.NonInteractive { - return false - } - if opts.User || opts.Project || opts.Cwd { - return false - } - if strings.TrimSpace(opts.Path) != "" || strings.TrimSpace(opts.Hub) != "" { - return false - } - if strings.TrimSpace(opts.Keg) != "" { - return false - } - return true -} - -// promptInitOptions walks the user through alias / location / metadata when keg -// create is invoked bare on a TTY. Prompts go to stderr (so stdout stays clean -// for the success line that downstream tooling may pipe), and answers come from -// cmd.InOrStdin() so tests can pipe scripted answers via Process.RunWithIO. -// -// The hub branch is intentionally skipped: hub init still requires the user -// to pass --hub explicitly, since hub setup needs a namespace + token and the -// terse prompt is not the right place to teach that flow. -func promptInitOptions(cmd *cobra.Command, deps *Deps, opts *tapper.InitOptions) error { - reader := bufio.NewReader(cmd.InOrStdin()) - stderr := cmd.ErrOrStderr() - - defaultAlias := "" - if deps != nil && deps.Runtime != nil { - if cwd, err := deps.Runtime.Getwd(); err == nil && cwd != "" { - defaultAlias = filepath.Base(cwd) - } - } - - alias, err := promptLine(stderr, reader, fmt.Sprintf("keg alias [%s]: ", defaultAlias)) - if err != nil { - return err - } - if alias == "" { - alias = defaultAlias - } - if err := tapper.ValidateKegAlias(alias); err != nil { - return err - } - opts.Keg = alias - - location, err := promptLine(stderr, reader, "location [user/project] (default user): ") - if err != nil { - return err - } - switch strings.ToLower(strings.TrimSpace(location)) { - case "", "user", "u": - opts.User = true - case "project", "p": - opts.Project = true - default: - return fmt.Errorf("invalid location %q: expected user or project", location) - } - - title, err := promptLine(stderr, reader, "title (optional): ") - if err != nil { - return err - } - if title != "" { - opts.Title = title - } - - creator, err := promptLine(stderr, reader, "creator (optional): ") - if err != nil { - return err - } - if creator != "" { - opts.Creator = creator - } - - return nil -} - -// promptLine writes prompt to w, reads a single line from r, and returns the -// trimmed answer. Treats io.EOF as a terminating empty answer so a piped -// stdin that closes after fewer responses than prompts behaves as if each -// remaining prompt accepted its default. -func promptLine(w io.Writer, r *bufio.Reader, prompt string) (string, error) { - if _, err := fmt.Fprint(w, prompt); err != nil { - return "", err - } - line, err := r.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - return strings.TrimSpace(line), nil -} diff --git a/pkg/cli/cmd_init_test.go b/pkg/cli/cmd_init_test.go deleted file mode 100644 index f8f91ad3..00000000 --- a/pkg/cli/cmd_init_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package cli_test - -import ( - "path/filepath" - "strings" - "testing" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/stretchr/testify/require" -) - -type initTestCase struct { - name string - args []string - expectedAlias string - expectedLocation string - expectedStdout []string - expectConfigUpdate bool - setupFixture *string - cwd *string - description string -} - -func TestInitCommand_TableDriven(t *testing.T) { - tests := []initTestCase{ - { - name: "local_keg_named_project_defaults_to_kegs_alias", - args: []string{ - "init", - "--project", - "--keg", "power", - "--creator", "me", - }, - expectedAlias: "power", - expectedLocation: "~/kegs/power", - expectedStdout: []string{ - "keg power created (file-backed)", - }, - description: "When --project, destination should default to kegs/ under project root", - }, - { - name: "local_keg_with_cwd_without_project", - args: []string{ - "init", - "--cwd", - "--keg", "power", - "--creator", "me", - }, - expectedAlias: "power", - expectedLocation: "~/myproject/kegs/power", - expectedStdout: []string{ - "keg power created (file-backed)", - }, - cwd: strPtr("~/myproject"), - description: "When --cwd is set without --project, destination should still resolve as a local keg under the current working directory", - }, - { - name: "local_keg_with_path_without_project", - args: []string{ - "init", - "--path", ".", - "--keg", "workspace", - "--creator", "me", - }, - expectedAlias: "workspace", - expectedLocation: "~/myproject", - expectedStdout: []string{ - "keg workspace created (file-backed)", - }, - cwd: strPtr("~/myproject"), - description: "When --path is set without --project, destination should resolve as a local keg at the explicit path", - }, - { - name: "local_keg_with_explicit_alias", - args: []string{ - "init", - "--project", - "--keg", "myalias", - "--creator", "me", - }, - expectedAlias: "myalias", - expectedLocation: "~/kegs/myalias", - description: "When --project with explicit --keg, default destination should be kegs/ under project root", - }, - { - name: "local_keg_infers_alias_from_cwd", - args: []string{ - "init", - "--project", - "--creator", "me", - }, - expectedAlias: "myproject", - expectedLocation: "~/myproject/kegs/myproject", - cwd: strPtr("~/myproject"), - description: "Project keg should infer alias from current working directory base when --keg not provided", - }, - { - name: "local_keg_project_explicit_alias", - args: []string{ - "init", - "--project", - "--keg", "myalias", - "--creator", "me", - }, - expectedAlias: "myalias", - expectedLocation: "~/kegs/myalias", - description: "Project keg with explicit --project and --keg flags", - }, - { - name: "user_keg_defaults_to_user_type", - args: []string{ - "init", - "--keg", "public", - "--creator", "testcreator", - }, - expectedAlias: "public", - expectedLocation: "~/kegs/@local/public", - expectConfigUpdate: false, - setupFixture: strPtr("testuser"), - description: "When no destination flag is provided, default destination should be user", - }, - { - name: "user_keg_with_explicit_type", - args: []string{ - "init", - "--user", - "--keg", "public", - "--creator", "testcreator", - }, - expectedAlias: "public", - expectedLocation: "~/kegs/@local/public", - expectConfigUpdate: false, - setupFixture: strPtr("testuser"), - description: "User keg with explicit --user flag", - }, - { - name: "user_keg_with_explicit_alias", - args: []string{ - "init", - "--keg", "myblog", - "--creator", "me", - }, - expectedAlias: "myblog", - expectedLocation: "~/kegs/@local/myblog", - expectConfigUpdate: false, - setupFixture: strPtr("testuser"), - description: "User keg should use --keg alias for directory name", - }, - { - name: "user_type_infers_alias_from_cwd", - args: []string{ - "init", - "--user", - "--creator", "me", - }, - expectedAlias: "myproject", - expectedLocation: "~/kegs/@local/myproject", - expectConfigUpdate: false, - setupFixture: strPtr("testuser"), - cwd: strPtr("/home/testuser/myproject"), - description: "When --keg is omitted with --user, alias should infer from current working directory base", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(innerT *testing.T) { - innerT.Parallel() - var opts []testutils.Option - if tt.setupFixture != nil { - opts = append(opts, testutils.WithFixture(*tt.setupFixture, "~")) - } - sb := NewSandbox(innerT, opts...) - - if tt.cwd != nil { - sb.Setwd(*tt.cwd) - } - - h := NewProcess(innerT, false, tt.args...) - res := h.Run(sb.Context(), sb.Runtime()) - - require.NoError(innerT, res.Err, "init command should succeed - %s", tt.description) - require.Contains(innerT, string(res.Stdout), "keg "+tt.expectedAlias+" created", - "unexpected output: %q", string(res.Stdout)) - for _, fragment := range tt.expectedStdout { - require.Contains(innerT, string(res.Stdout), fragment, - "expected output to contain %q, got %q", fragment, string(res.Stdout)) - } - require.NotContains(innerT, string(res.Stderr), "level=ERROR", "stderr should not contain errors") - - // Determine the base path for reading files (remove /dex/nodes.tsv from the location) - var baseKegPath string - if tt.setupFixture != nil { - // User kegs land on the local hub at /@local/{alias} - baseKegPath = "~/kegs/@local/" + tt.expectedAlias - } else { - // Project kegs are at the repo root - baseKegPath = "" - } - - // Verify the created keg contains the example contents - nodesPath := baseKegPath - if nodesPath != "" { - nodesPath = filepath.Join(baseKegPath, "/dex/nodes.tsv") - } else { - nodesPath = filepath.Join(tt.expectedLocation, "dex/nodes.tsv") - } - nodes := sb.MustReadFile(nodesPath) - require.Contains(innerT, string(nodes), "0\t", - "nodes index should contain zero node") - - readmePath := baseKegPath - if readmePath != "" { - readmePath += "/0/README.md" - } else { - readmePath = filepath.Join(tt.expectedLocation, "0/README.md") - } - readme := sb.MustReadFile(readmePath) - require.Contains(innerT, string(readme), - "Sorry, planned but not yet available", - "zero node README should contain placeholder text") - - statsPath := baseKegPath - if statsPath != "" { - statsPath += "/0/stats.json" - } else { - statsPath = filepath.Join(tt.expectedLocation, "0/stats.json") - } - stats := sb.MustReadFile(statsPath) - require.Contains(innerT, string(stats), - `"title":"Sorry, planned but not yet available"`, - "zero node stats should include the placeholder title") - - kegPath := baseKegPath - if kegPath != "" { - kegPath += "/keg" - } else { - kegPath = filepath.Join(tt.expectedLocation, "keg") - } - kegConfig := sb.MustReadFile(kegPath) - require.Contains(innerT, string(kegConfig), - "# yaml-language-server: $schema=https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/keg-config.json", - "keg config should include schema modeline") - - // For user kegs, verify config was updated - if tt.setupFixture != nil { - userConfig := sb.MustReadFile("~/.config/tapper/config.yaml") - - if tt.expectConfigUpdate { - require.Contains(innerT, string(userConfig), tt.expectedAlias+":", - "user config should contain the new keg alias") - } else { - require.NotContains(innerT, string(userConfig), tt.expectedAlias+":", - "user config should contain the new keg alias") - } - } - }) - } -} - -func strPtr(s string) *string { - return &s -} - -func TestInitCommand_DestinationValidation(t *testing.T) { - t.Run("project_and_user_flags_conflict", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT) - - h := NewProcess(innerT, false, "init", "--keg", "blog", "--project", "--user") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "cannot be combined with a local destination") - }) - - t.Run("cwd_conflicts_with_user_flag", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT) - - h := NewProcess(innerT, false, "init", "--keg", "blog", "--cwd", "--user") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "cannot be combined with a local destination") - }) -} - -// TestInitCommand_RequiresBootstrap confirms that a namespace/hub create — here -// --user, which targets this machine's local hub — refuses on the full `tap` -// surface until `tap bootstrap` has been run, rather than silently materializing -// a keg in a hidden platform dir. -func TestInitCommand_RequiresBootstrap(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - h := NewProcess(t, false, "init", "--user", "--keg", "fresh", "--creator", "me") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(t, res.Err, "unconfigured --user create should require bootstrap") - require.Contains(t, string(res.Stderr), "tap bootstrap") -} - -// localHubUserConfig is a minimal bootstrapped user config: a single local hub -// keyed "home" whose basePath is the platform-default keg root, so @local -// resolves there. Used by init tests that exercise prompt/flag mechanics and -// need a create to succeed. -const localHubUserConfig = "fallbackHub: home\n" + - "namespaces:\n local:\n hub: home\n" + - "hubs:\n home:\n kind: local\n defaultNamespace: local\n" + - " basePath: ~/.local/share/tapper/kegs\n" - -func TestInitCommand_RejectsInvalidAlias(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - alias string - }{ - {"uppercase", "Blog"}, - {"space", "my blog"}, - {"slash", "kegs/blog"}, - {"dot", "blog.keg"}, - } - - for _, c := range cases { - c := c - t.Run(c.name, func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) - - h := NewProcess(innerT, false, "init", "--user", "--keg", c.alias) - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "invalid keg alias") - }) - } -} - -// TestInitCommand_InteractivePrompt covers the TTY-gated prompt path: -// when stdin is a TTY and no destination flags are supplied, tap init -// asks for alias / location / title / creator. We pipe scripted answers -// via RunWithIO and assert that the resulting keg uses the alias from -// the prompt (not cwd basename) and lands in the user destination. -func TestInitCommand_InteractivePrompt(t *testing.T) { - t.Parallel() - sb := NewSandbox(t) - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(localHubUserConfig), 0o644) - - answers := strings.Join([]string{ - "diary", // alias - "user", // location - "My Diary", // title - "me@example", // creator - "", // trailing newline buffer - }, "\n") - - h := NewProcess(t, true, "init") - res := h.RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader(answers)) - require.NoError(t, res.Err, "interactive init should succeed: stderr=%q", string(res.Stderr)) - require.Contains(t, string(res.Stdout), "keg diary created (file-backed)") - - keg := sb.MustReadFile("~/.local/share/tapper/kegs/@local/diary/keg") - require.Contains(t, string(keg), "$schema=", "interactive init should have written the platform-default user keg") - require.Contains(t, string(keg), "title: My Diary") - require.Contains(t, string(keg), "creator: me@example") -} - -// TestInitCommand_NonInteractiveFlagSkipsPrompt confirms that -// --non-interactive bypasses the TTY prompt even when stdin is a TTY, -// so scripted invocations on attended terminals can rely on flag -// defaults without piping answers. -func TestInitCommand_NonInteractiveFlagSkipsPrompt(t *testing.T) { - t.Parallel() - sb := NewSandbox(t) - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(localHubUserConfig), 0o644) - - h := NewProcess(t, true, "init", "--non-interactive", "--keg", "ci", "--user", "--creator", "ci-bot") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "init --non-interactive on TTY should succeed without piped stdin") - require.Contains(t, string(res.Stdout), "keg ci created (file-backed)") - require.NotContains(t, string(res.Stderr), "keg alias [") -} - -// TestInitCommand_NonTTYSkipsPrompt confirms that bare `tap init` -// without a TTY (CI, MCP, pipes) does not block waiting for prompt -// answers — it falls back to alias inference from cwd and the platform -// default user destination. This is the behavior MCP relies on. -func TestInitCommand_NonTTYSkipsPrompt(t *testing.T) { - t.Parallel() - sb := NewSandbox(t) - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(localHubUserConfig), 0o644) - require.NoError(t, sb.Setwd("/home/testuser/scratch")) - - h := NewProcess(t, false, "init") - res := h.Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err, "non-TTY bare init should succeed via cwd-inferred alias") - require.Contains(t, string(res.Stdout), "keg scratch created (file-backed)") -} diff --git a/pkg/cli/cmd_integrate_completion_test.go b/pkg/cli/cmd_integrate_completion_test.go index a4cc5ea5..adbad324 100644 --- a/pkg/cli/cmd_integrate_completion_test.go +++ b/pkg/cli/cmd_integrate_completion_test.go @@ -82,18 +82,16 @@ func TestRootCompletion_FlightFlagSuppressesFileCompletion(t *testing.T) { require.Contains(t, out, expected) } -func TestRootCompletion_FlightFlagSuggestsLocalFlights(t *testing.T) { +func TestRootCompletion_FlightFlagSuggestsRemoteFlights(t *testing.T) { t.Parallel() - sb := NewSandbox(t) - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644) - sb.MustWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: Backend\ninstructions: Stay focused.\n"), 0o644) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "--flight", ""). Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/+backend") + require.Contains(t, suggestions, "@team/+backend") require.Contains(t, string(comp.Stdout), fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp)) } diff --git a/pkg/cli/cmd_keg.go b/pkg/cli/cmd_keg.go index 89f875ae..27d4b4db 100644 --- a/pkg/cli/cmd_keg.go +++ b/pkg/cli/cmd_keg.go @@ -40,18 +40,11 @@ settings.`, newKegRenameCmd(deps), newKegSettingsCmd(deps), ) - // `keg create` carries the keg-creation surface, gated to the same profile - // that historically exposed `tap init` (the pruned `keg` binary omits it). - if deps.Profile.IncludeRepoCommand { - createCmd := newKegCreateCmd(deps) - // create re-binds --keg/--project/--path/--cwd locally with create-time - // semantics; strip the inherited keg-target persistent flags from its - // "Global Flags" help so users don't see two entries for each name. - if deps.Profile.withDefaults().AllowKegAliasFlags { - filterRepoTargetFlagsInHelp(createCmd) - } - cmd.AddCommand(createCmd) + createCmd := newKegCreateCmd(deps) + if deps.Profile.withDefaults().AllowKegAliasFlags { + filterRepoTargetFlagsInHelp(createCmd) } + cmd.AddCommand(createCmd) return cmd } @@ -196,8 +189,8 @@ func newKegSettingsCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "settings", - Short: "display keg configuration", - Long: `Display the keg configuration (keg file contents). + Short: "display keg settings", + Long: `Display the keg settings (keg file contents). Shows metadata about the keg including title, creator, links, schema policy, and other configuration properties. Use 'tap keg settings edit' to modify the keg @@ -219,19 +212,24 @@ configuration.`, } func newKegSettingsEditCmd(deps *Deps) *cobra.Command { - var opts tapper.KegConfigEditOptions + var opts tapper.KegSettingsEditOptions cmd := &cobra.Command{ Use: "edit", - Short: "edit keg configuration with default editor", - Long: `Open the keg configuration in your default editor for editing. + Short: "edit keg settings with default editor", + Long: `Open the keg settings in your default editor for editing. If stdin is piped with non-empty YAML, the piped content is validated and written directly instead of opening an editor.`, RunE: func(cmd *cobra.Command, args []string) error { applyKegTargetProfile(deps, &opts.KegTargetOptions) + hash, err := deps.Tap.KegSettingsHash(cmd.Context(), opts.KegTargetOptions) + if err != nil { + return err + } + opts.ExpectedHash = hash opts.Stream = deps.Runtime.Stream() - return deps.Tap.KegConfigEdit(cmd.Context(), opts) + return deps.Tap.KegSettingsEdit(cmd.Context(), opts) }, } return cmd diff --git a/pkg/cli/cmd_keg_create_remote_test.go b/pkg/cli/cmd_keg_create_remote_test.go new file mode 100644 index 00000000..dda13489 --- /dev/null +++ b/pkg/cli/cmd_keg_create_remote_test.go @@ -0,0 +1,88 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKegCreateUsesConfiguredHubExclusively(t *testing.T) { + t.Parallel() + + var got map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/@team/kegs", r.URL.Path) + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + sb := NewSandbox(t) + sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf(`fallbackHub: test +fallbackNamespace: team +hubs: + test: + kind: remote + url: %s + token: test-token +`, srv.URL)), 0o644) + + res := NewProcess(t, false, "keg", "create", "notes", "--title", "Team Notes", "--visibility", "private").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err, "stderr=%q", res.Stderr) + require.Equal(t, map[string]string{ + "alias": "notes", "title": "Team Notes", "visibility": "private", + }, got) + require.Contains(t, string(res.Stdout), "keg notes created") + require.Contains(t, string(res.Stdout), "keg:@team/notes") + + config := string(sb.MustReadFile("~/.config/tapper/config.yaml")) + require.Contains(t, config, "team:") + require.Contains(t, config, "hub: test") +} + +func TestKegCreateRejectsRemovedLocalSurfaces(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"init", "notes"}, + {"keg", "create", "notes", "--project"}, + {"keg", "create", "notes", "--user"}, + {"keg", "create", "notes", "--cwd"}, + {"keg", "create", "notes", "--path", "/tmp/notes"}, + } { + args := args + t.Run(fmt.Sprintf("%v", args), func(t *testing.T) { + t.Parallel() + sb := NewSandbox(t) + res := NewProcess(t, false, args...).Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + }) + } +} + +func TestKegCreateRejectsReadonlyAndInvalidAlias(t *testing.T) { + t.Parallel() + + sb := NewSandbox(t) + sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(`fallbackHub: archive +fallbackNamespace: team +hubs: + archive: + kind: readonly + url: https://archive.example.com +`), 0o644) + + readonly := NewProcess(t, false, "keg", "create", "notes").Run(sb.Context(), sb.Runtime()) + require.Error(t, readonly.Err) + require.Contains(t, readonly.Err.Error(), "does not support KEG creation") + + invalid := NewProcess(t, false, "keg", "create", "Bad.Name").Run(sb.Context(), sb.Runtime()) + require.Error(t, invalid.Err) + require.Contains(t, invalid.Err.Error(), "invalid keg alias") +} diff --git a/pkg/cli/cmd_keg_info_test.go b/pkg/cli/cmd_keg_info_test.go index 36fe6131..fd9bc2e0 100644 --- a/pkg/cli/cmd_keg_info_test.go +++ b/pkg/cli/cmd_keg_info_test.go @@ -87,12 +87,12 @@ func TestInfoCommand_WithNonexistentAliasErrors(t *testing.T) { require.Contains(t, string(res.Stderr), "keg not initialized") } -func TestInfoCommand_WithInvalidKegConfigErrors(t *testing.T) { +func TestInfoCommand_WithInvalidKegSettingsErrors(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) sb.MustWriteFile("~/kegs/@local/example/keg", []byte("kegv: [\n"), 0o644) res := NewProcess(t, false, "info", "--keg", "example").Run(sb.Context(), sb.Runtime()) require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "unable to read keg config") + require.Contains(t, string(res.Stderr), "unable to read keg settings") } diff --git a/pkg/cli/cmd_keg_target_completion_test.go b/pkg/cli/cmd_keg_target_completion_test.go index e28bd0d4..b0387e65 100644 --- a/pkg/cli/cmd_keg_target_completion_test.go +++ b/pkg/cli/cmd_keg_target_completion_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - testutils "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -13,15 +13,15 @@ import ( // logical keg references from the configured hubs, not filesystem paths. func TestKegFlagCompletion_HappyPath(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "--keg", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/example") - require.Contains(t, suggestions, "@local/personal") - require.Contains(t, suggestions, "@local/work") + require.Contains(t, suggestions, "@team/example") + require.Contains(t, suggestions, "@team/personal") + require.Contains(t, suggestions, "@team/work") require.Contains(t, suggestions, "example") require.Contains(t, suggestions, "personal") require.Contains(t, suggestions, "work") @@ -30,13 +30,13 @@ func TestKegFlagCompletion_HappyPath(t *testing.T) { func TestKegFlagCompletion_ShortFlag(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "-k", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/personal") + require.Contains(t, suggestions, "@team/personal") require.Contains(t, suggestions, "personal") } @@ -44,7 +44,7 @@ func TestKegFlagCompletion_ShortFlag(t *testing.T) { // bare logical names in the active namespace. func TestKegFlagCompletion_PrefixFilter(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "--keg", "per").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) @@ -55,20 +55,20 @@ func TestKegFlagCompletion_PrefixFilter(t *testing.T) { func TestKegFlagCompletion_CanonicalPrefixFilter(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) - comp := NewCompletionProcess(t, false, 0, "--keg", "@local/p").Run(sb.Context(), sb.Runtime()) + comp := NewCompletionProcess(t, false, 0, "--keg", "@team/p").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Equal(t, []string{"@local/personal"}, suggestions) + require.Equal(t, []string{"@team/personal"}, suggestions) } // TestKegFlagCompletion_NoMatches verifies that completing --keg with an // unmatched prefix returns an empty suggestion list (not an error). func TestKegFlagCompletion_EmptyConfig(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) + sb := NewRemoteKegListSandbox(t, nil) comp := NewCompletionProcess(t, false, 0, "--keg", "zzz").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) @@ -79,7 +79,7 @@ func TestKegFlagCompletion_EmptyConfig(t *testing.T) { func TestKegFlagCompletion_RemoteFailureIsBestEffort(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewSandbox(t) sb.MustWriteFile("~/.config/tapper/config.yaml", []byte("hubs:\n atlas:\n kind: remote\n url: https://atlas.foldwise.ai\n"), 0o644) comp := NewCompletionProcess(t, false, 0, "--keg", "").Run(sb.Context(), sb.Runtime()) @@ -90,35 +90,22 @@ func TestKegFlagCompletion_RemoteFailureIsBestEffort(t *testing.T) { require.Contains(t, string(comp.Stdout), fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp)) } -// TestKegProfile_NoKegFlagCompletion verifies that the keg binary (which -// sets AllowKegAliasFlags=false) returns no suggestions for --keg. -func TestKegProfile_NoKegFlagCompletion(t *testing.T) { - t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - - comp := NewCompletionProcess(t, false, 0, "--keg", "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - - // tap registers the --keg flag completer and enumerates logical kegs. - tapSuggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, tapSuggestions, "@local/personal") - - // keg has no --keg flag; __complete should return no matches for it. - kegComp := NewKegProcess(t, false, "__complete", "--keg", "").Run(sb.Context(), sb.Runtime()) - kegSuggestions := parseCompletionSuggestions(string(kegComp.Stdout)) - require.Empty(t, kegSuggestions) -} - -// TestKegFlagCompletion_IndexSubcommand verifies that the global --keg flag -// completion is wired on index subcommands. func TestKegFlagCompletion_IndexSubcommand(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "index", "rebuild", "--keg", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/personal") + require.Contains(t, suggestions, "@team/personal") require.Contains(t, suggestions, "personal") } + +func remoteCompletionKegs() []tapper.HubKeg { + return []tapper.HubKeg{ + {Namespace: "team", Alias: "example", Visibility: "private", Role: "admin"}, + {Namespace: "team", Alias: "personal", Visibility: "private", Role: "admin"}, + {Namespace: "team", Alias: "work", Visibility: "private", Role: "editor"}, + } +} diff --git a/pkg/cli/cmd_keg_test.go b/pkg/cli/cmd_keg_test.go index 63dc3c6f..1cbee499 100644 --- a/pkg/cli/cmd_keg_test.go +++ b/pkg/cli/cmd_keg_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "testing" - testutils "github.com/jlrickert/cli-toolkit/sandbox" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -39,22 +38,22 @@ namespaces: res := NewProcess(t, false, "keg", "rename", "@jlrickert/example", "renamed").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - require.Equal(t, "/api/v1/@jlrickert/kegs/example/settings", gotPath) + require.Equal(t, "/api/v1/@jlrickert/kegs/example/rename", gotPath) require.Equal(t, map[string]string{"alias": "renamed"}, gotBody) } func TestKegRenameCompletion_OldArgListsKegs(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "keg", "rename", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/example") - require.Contains(t, suggestions, "@local/personal") - require.Contains(t, suggestions, "@local/work") + require.Contains(t, suggestions, "@team/example") + require.Contains(t, suggestions, "@team/personal") + require.Contains(t, suggestions, "@team/work") require.Contains(t, suggestions, "example") require.Contains(t, suggestions, "personal") require.Contains(t, suggestions, "work") @@ -64,9 +63,9 @@ func TestKegRenameCompletion_OldArgListsKegs(t *testing.T) { func TestKegRenameCompletion_NewArgSuppressesFileCompletion(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) - comp := NewCompletionProcess(t, false, 0, "keg", "rename", "@local/personal", "").Run(sb.Context(), sb.Runtime()) + comp := NewCompletionProcess(t, false, 0, "keg", "rename", "@team/personal", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) require.Empty(t, parseCompletionSuggestions(string(comp.Stdout))) diff --git a/pkg/cli/cmd_launch.go b/pkg/cli/cmd_launch.go index 46594d3f..4a2c25ef 100644 --- a/pkg/cli/cmd_launch.go +++ b/pkg/cli/cmd_launch.go @@ -15,31 +15,42 @@ import ( ) // NewLaunchCmd builds the `tap launch` command. It resolves a configured agent -// to its model and flight and starts the named harness with that context. +// to its model and starts the named harness under the configured root flight. func NewLaunchCmd(deps *Deps) *cobra.Command { var opts tapper.LaunchOptions cmd := &cobra.Command{ Use: "launch HARNESS [-- ARGS...]", Short: "start an agent CLI with a configured model and flight (experimental)", - Long: `Start Claude Code, Codex, or pi with the model and flight named by a -configured agent. + Long: `Start Claude Code, Codex, or pi with a configured agent model and the +current Hub-backed flight as a connection-pinned root. -An agent is an alias for a (model, flight) pair: +An agent selects only a model: agents: opus: model: anthropic/claude-opus-4 - flight: +dev local: model: ollama/qwen3.6:35b - flight: "@me/+scratch" Models are provider-qualified so the launcher knows which protocol the harness -must speak. The agent name is exported as TAP_AGENT, so a tap mcp session -started inside the harness resolves the agent's flight for itself. Editing the -agent's flight and calling orient again therefore moves a running session, -which exporting the resolved flight would not. +must speak. TAP_AGENT carries model selection and telemetry only. + +--agent picks which entry to use. When it is omitted the top-level 'agent' key +is used instead, the same way 'flight' supplies the launch root: + + agent: opus + +The launch root follows normal flight precedence: explicit --flight, +TAP_FLIGHT, project flight, then the user baseline. It is resolved once, must +be Hub-backed, and is exported canonically as TAP_FLIGHT. Governed MCP calls +reload that root's live graph and may select an accessible transitive +descendant; they cannot switch roots. + +A flight is optional. With none configured the harness starts under no-flight +identity authority — full access to every KEG the account can already reach, +which is what lets a fresh account launch an agent to create its first flight. +The launcher warns when it does this. Selecting a flight is how you narrow it. Arguments after -- are passed through to the harness. @@ -56,6 +67,7 @@ Experimental and unstable: expect this to change or disappear.`, RunE: func(cmd *cobra.Command, args []string) error { opts.Harness = args[0] opts.Args = args[1:] + opts.Flight = deps.KegTargetOptions.Flight result, err := deps.Tap.Launch(cmd.Context(), opts) if err != nil { @@ -71,10 +83,7 @@ Experimental and unstable: expect this to change or disappear.`, return err } if result.Flight != "" { - // "resolves to" rather than "is": the child re-resolves this - // from TAP_AGENT on every orient, so it can change under a - // running session. - if _, err := fmt.Fprintf(out, "flight: %s (resolves to, via agent)\n", result.Flight); err != nil { + if _, err := fmt.Fprintf(out, "flight: %s (connection-pinned root)\n", result.Flight); err != nil { return err } } @@ -117,7 +126,8 @@ Experimental and unstable: expect this to change or disappear.`, }, } - cmd.Flags().StringVar(&opts.Agent, "agent", "", "configured agent alias supplying the model and flight") + cmd.Flags().StringVar(&opts.Agent, "agent", "", + "configured agent alias supplying the model (default: the config's agent key, or TAP_AGENT)") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print the resolved invocation without starting the harness") mustRegisterFlagCompletion(cmd, "agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return configAgentNames(deps), cobra.ShellCompDirectiveNoFileComp diff --git a/pkg/cli/cmd_launch_test.go b/pkg/cli/cmd_launch_test.go index c153214e..62fe6919 100644 --- a/pkg/cli/cmd_launch_test.go +++ b/pkg/cli/cmd_launch_test.go @@ -8,6 +8,12 @@ import ( ) const launchConfig = `fallbackNamespace: local +flight: "@testuser/+root" +defaultHub: atlas +hubs: + atlas: + kind: remote + url: https://atlas.example.test agents: opus: model: anthropic/claude-opus-4 @@ -43,12 +49,12 @@ func TestLaunchCommand_DryRunResolvesOllamaThroughOpenAI(t *testing.T) { out := string(res.Stdout) require.Contains(t, out, "agent local -> ollama/qwen3.6:35b-mlx") - require.Contains(t, out, "flight: @testuser/+scratch") + require.Contains(t, out, "flight: @testuser/+root (connection-pinned root)") require.Contains(t, out, "codex --oss --local-provider ollama --model qwen3.6:35b-mlx") require.Contains(t, out, "CODEX_OSS_BASE_URL=http://localhost:11434/v1") require.Contains(t, out, "TAP_AGENT=local") - require.NotContains(t, out, "TAP_FLIGHT=") + require.Contains(t, out, "TAP_FLIGHT=@testuser/+root") } func TestLaunchCommand_DryRunResolvesAnthropicThroughEnv(t *testing.T) { @@ -62,7 +68,23 @@ func TestLaunchCommand_DryRunResolvesAnthropicThroughEnv(t *testing.T) { out := string(res.Stdout) require.Contains(t, out, "ANTHROPIC_MODEL=claude-opus-4") require.Contains(t, out, "TAP_AGENT=opus") - require.NotContains(t, out, "TAP_FLIGHT=") + require.Contains(t, out, "TAP_FLIGHT=@testuser/+root") +} + +func TestLaunchCommand_DryRunHonorsExplicitFlight(t *testing.T) { + t.Parallel() + sb := newLaunchSandbox(t) + require.NoError(t, sb.Runtime().Env().Set("TAP_FLIGHT", "@environment/+root")) + + res := NewProcess(t, false, "launch", "claude", "--agent", "opus", "--dry-run", + "--flight", "@admin/+mcp-smoke-root").Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + + out := string(res.Stdout) + require.Contains(t, out, "flight: @admin/+mcp-smoke-root (connection-pinned root)") + require.Contains(t, out, "TAP_FLIGHT=@admin/+mcp-smoke-root") + require.NotContains(t, out, "@environment/+root") + require.NotContains(t, out, "@testuser/+root") } func TestLaunchCommand_DryRunPassesThroughExtraArgs(t *testing.T) { @@ -117,6 +139,40 @@ func TestLaunchCommand_DryRunReportsSubscriptionStrip(t *testing.T) { require.Contains(t, out, "unset: ANTHROPIC_API_KEY (inherited)") } +// Launching with no flight is the bootstrap path: it must resolve rather than +// error, report no pinned root, and leave TAP_FLIGHT unset so the harness's +// `tap mcp` resolves identity authority. The warning goes to stderr so it cannot +// corrupt a piped dry-run report. +func TestLaunchCommand_DryRunWithoutFlightWarnsAndPinsNothing(t *testing.T) { + t.Parallel() + sb := NewSandbox(t) + require.NoError(t, sb.Runtime().AtomicWriteFile( + "/home/testuser/.config/tapper/config.yaml", []byte(`fallbackNamespace: local +defaultHub: atlas +hubs: + atlas: + kind: remote + url: https://atlas.example.test +agents: + opus: + model: anthropic/claude-opus-4 +`), 0o644)) + + res := NewProcess(t, false, "launch", "claude", "--agent", "opus", "--dry-run"). + Run(sb.Context(), sb.Runtime()) + require.NoError(t, res.Err) + + out := string(res.Stdout) + require.Contains(t, out, "agent opus -> anthropic/claude-opus-4") + require.NotContains(t, out, "connection-pinned root") + require.NotContains(t, out, "TAP_FLIGHT") + require.Contains(t, out, "TAP_AGENT=opus") + + require.Contains(t, string(res.Stderr), "identity-authorized full access") + require.NotContains(t, out, "identity-authorized full access", + "the warning belongs on stderr, clear of the dry-run report") +} + func TestLaunchCommand_ErrorsOnUnknownAgent(t *testing.T) { t.Parallel() sb := newLaunchSandbox(t) diff --git a/pkg/cli/cmd_list_test.go b/pkg/cli/cmd_list_test.go index 9a41d17b..27129f53 100644 --- a/pkg/cli/cmd_list_test.go +++ b/pkg/cli/cmd_list_test.go @@ -625,7 +625,7 @@ func TestListCommand_FormatCompletionSuggestsSelectors(t *testing.T) { require.Contains(t, suggestions, "%{.accessCount}") } -func TestListCommand_KegConfigListFieldsDrivesDefault(t *testing.T) { +func TestListCommand_KegSettingsListFieldsDrivesDefault(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("queryuser", "~")) @@ -646,7 +646,7 @@ func TestListCommand_KegConfigListFieldsDrivesDefault(t *testing.T) { require.NotContains(t, out, "T00:00:00Z") } -func TestListCommand_ExplicitFormatBeatsKegConfig(t *testing.T) { +func TestListCommand_ExplicitFormatBeatsKegSettings(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("queryuser", "~")) diff --git a/pkg/cli/cmd_mcp.go b/pkg/cli/cmd_mcp.go index 869b056a..a5a0e7f2 100644 --- a/pkg/cli/cmd_mcp.go +++ b/pkg/cli/cmd_mcp.go @@ -30,7 +30,7 @@ per-command permission prompts.`, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { rt := deps.Runtime - launcherBound := cmd.Flags().Changed("flight") + launcherBound := cmd.Flags().Changed("flight") || rt.Env().Get("TAP_FLIGHT") != "" // MCP servers communicate over stdio, so the logger must // write to stderr. When no --log-file is provided, use diff --git a/pkg/cli/cmd_meta_test.go b/pkg/cli/cmd_meta_test.go index a6cbe577..e492aa55 100644 --- a/pkg/cli/cmd_meta_test.go +++ b/pkg/cli/cmd_meta_test.go @@ -32,7 +32,7 @@ func TestMetaCommand_TableDrivenErrors(t *testing.T) { name: "missing_alias", args: []string{"meta", "0", "--keg", "missing"}, fixture: strPtr("joe"), - expectedErr: "node 0 not found", + expectedErr: "keg not initialized", }, { name: "missing_node", @@ -93,7 +93,7 @@ tags: require.NoError(t, res.Err) require.Equal(t, "", strings.TrimSpace(string(res.Stdout))) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: replaced") require.Contains(t, meta, "- alpha") require.Contains(t, meta, "- zeta") @@ -109,7 +109,7 @@ func TestMetaCommand_ReplaceFromStdinPersistsSchemaSelection(t *testing.T) { stdin := strings.NewReader("summary: selected\n") res := NewProcess(t, false, "meta", "1", "--keg", "personal", "--schema", "note").RunWithIO(sb.Context(), sb.Runtime(), stdin) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/1/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "1") require.Contains(t, meta, "summary: selected") require.Contains(t, meta, "type: note") } @@ -118,13 +118,13 @@ func TestMetaCommand_ReplaceFromStdinRejectsInvalidYaml(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - before := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + before := fixtureMeta(t, sb.Runtime(), "personal", "0") stdin := strings.NewReader("tags: [\n") res := NewProcess(t, false, "meta", "0", "--keg", "personal").RunWithIO(sb.Context(), sb.Runtime(), stdin) require.Error(t, res.Err) require.Contains(t, string(res.Stderr), "metadata from stdin is invalid") - after := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + after := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Equal(t, before, after) } @@ -159,7 +159,7 @@ EOF res := NewProcess(t, false, "meta", "0", "--keg", "personal", "--edit").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: after edit") require.Contains(t, meta, "- docs") require.Contains(t, meta, "- ops") @@ -195,12 +195,12 @@ EOF require.NoError(t, sb.Runtime().Set("EDITOR", "/bin/sh "+scriptPath)) sb.Runtime().Unset("VISUAL") - before := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + before := fixtureMeta(t, sb.Runtime(), "personal", "0") res := NewProcess(t, false, "meta", "0", "--keg", "personal", "--edit").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.Error(t, res.Err) require.Contains(t, string(res.Stderr), "node metadata is invalid after editing") - after := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + after := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Equal(t, before, after) } @@ -242,7 +242,7 @@ tags: require.Contains(t, string(initialRaw), "summary: from stdin") require.Contains(t, string(initialRaw), "- draft") - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: saved from editor") require.Contains(t, meta, "- final") } @@ -278,7 +278,7 @@ EOF res := NewProcess(t, false, "meta", "0", "--keg", "personal", "--edit").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - meta := string(sb.MustReadFile("~/kegs/@local/personal/0/meta.yaml")) + meta := fixtureMeta(t, sb.Runtime(), "personal", "0") require.Contains(t, meta, "summary: first valid meta") require.Contains(t, meta, "- live") } diff --git a/pkg/cli/cmd_mv.go b/pkg/cli/cmd_mv.go index b44ab028..52698cf1 100644 --- a/pkg/cli/cmd_mv.go +++ b/pkg/cli/cmd_mv.go @@ -1,6 +1,8 @@ package cli import ( + "fmt" + "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" ) @@ -22,6 +24,11 @@ destination must not already exist. Node 0 cannot be moved.`, opts.SourceID = args[0] opts.DestID = args[1] applyKegTargetProfile(deps, &opts.KegTargetOptions) + hash, err := deps.Tap.NodeHash(cmd.Context(), opts.KegTargetOptions, opts.SourceID) + if err != nil { + return fmt.Errorf("node %s not found: %w", opts.SourceID, err) + } + opts.ExpectedHash = hash return deps.Tap.Move(cmd.Context(), opts) }, } diff --git a/pkg/cli/cmd_mv_test.go b/pkg/cli/cmd_mv_test.go index cd3a9694..36e3cf58 100644 --- a/pkg/cli/cmd_mv_test.go +++ b/pkg/cli/cmd_mv_test.go @@ -17,20 +17,18 @@ func TestMoveCommand_RewritesLinks(t *testing.T) { res = NewProcess(t, false, "create", "--title", "Two").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - sb.MustWriteFile("~/kegs/@local/example/1/README.md", []byte("# One\n\nSee [two](../2).\nAlso ../2.\n"), 0o644) + fixtureSetContent(t, sb.Runtime(), "example", "1", "# One\n\nSee [two](../2).\nAlso ../2.\n") res = NewProcess(t, false, "mv", "2", "3").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - content := string(sb.MustReadFile("~/kegs/@local/example/1/README.md")) + content := fixtureContent(t, sb.Runtime(), "example", "1") require.Contains(t, content, "[two](../3)") require.Contains(t, content, "../3.") require.NotContains(t, content, "../2") - _, err := sb.Runtime().Stat("~/kegs/@local/example/2", false) - require.Error(t, err, "source node directory should be moved") - _, err = sb.Runtime().Stat("~/kegs/@local/example/3", false) - require.NoError(t, err, "destination node directory should exist") + require.False(t, fixtureNodeExists(t, sb.Runtime(), "example", "2"), "source node should be moved") + require.True(t, fixtureNodeExists(t, sb.Runtime(), "example", "3"), "destination node should exist") } func TestMoveCommand_ErrorCases(t *testing.T) { @@ -70,19 +68,17 @@ func TestMoveCommand_UpdatesAllBacklinksInFixture(t *testing.T) { res := NewProcess(t, false, "mv", "2", "5", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - content1 := string(sb.MustReadFile("~/kegs/@local/personal/1/README.md")) + content1 := fixtureContent(t, sb.Runtime(), "personal", "1") require.Contains(t, content1, "../5", "node 1 should reference the new id 5") require.NotContains(t, content1, "../2", "node 1 must not keep stale ref to 2") - content3 := string(sb.MustReadFile("~/kegs/@local/personal/3/README.md")) + content3 := fixtureContent(t, sb.Runtime(), "personal", "3") require.Contains(t, content3, "../5", "node 3 should reference the new id 5") require.NotContains(t, content3, "../2", "node 3 must not keep stale ref to 2") // Directory checks - _, err := sb.Runtime().Stat("~/kegs/@local/personal/2", false) - require.Error(t, err, "old node 2 directory should be gone") - _, err = sb.Runtime().Stat("~/kegs/@local/personal/5", false) - require.NoError(t, err, "new node 5 directory should exist") + require.False(t, fixtureNodeExists(t, sb.Runtime(), "personal", "2"), "old node 2 should be gone") + require.True(t, fixtureNodeExists(t, sb.Runtime(), "personal", "5"), "new node 5 should exist") } // TestMoveCommand_CreatesNodesViaStdinThenMoves creates nodes by piping content @@ -110,7 +106,7 @@ func TestMoveCommand_CreatesNodesViaStdinThenMoves(t *testing.T) { res = NewProcess(t, false, "mv", "5", "6", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - content4 := string(sb.MustReadFile("~/kegs/@local/personal/4/README.md")) + content4 := fixtureContent(t, sb.Runtime(), "personal", "4") require.Contains(t, content4, "../6") require.NotContains(t, content4, "../5") } diff --git a/pkg/cli/cmd_rm.go b/pkg/cli/cmd_rm.go index 3dfea9a4..f3adc37d 100644 --- a/pkg/cli/cmd_rm.go +++ b/pkg/cli/cmd_rm.go @@ -31,6 +31,14 @@ Inbound links from other nodes are cleaned up.`, RunE: func(cmd *cobra.Command, args []string) error { opts.NodeIDs = args applyKegTargetProfile(deps, &opts.KegTargetOptions) + opts.ExpectedHashes = make(map[string]string, len(args)) + for _, id := range args { + hash, err := deps.Tap.NodeHash(cmd.Context(), opts.KegTargetOptions, id) + if err != nil { + return fmt.Errorf("node %s not found: %w", id, err) + } + opts.ExpectedHashes[id] = hash + } return deps.Tap.Remove(cmd.Context(), opts) }, } diff --git a/pkg/cli/cmd_rm_test.go b/pkg/cli/cmd_rm_test.go index c5bf610d..dc0fe987 100644 --- a/pkg/cli/cmd_rm_test.go +++ b/pkg/cli/cmd_rm_test.go @@ -18,8 +18,7 @@ func TestRemoveCommand_DeletesNode(t *testing.T) { res = NewProcess(t, false, "rm", "1").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - _, err := sb.Runtime().Stat("~/kegs/@local/example/1", false) - require.Error(t, err, "node directory should be removed") + require.False(t, fixtureNodeExists(t, sb.Runtime(), "example", "1"), "node should be removed") catRes := NewProcess(t, false, "cat", "1").Run(sb.Context(), sb.Runtime()) require.Error(t, catRes.Err) @@ -56,16 +55,15 @@ func TestRemoveCommand_RedirectsLinksToZero(t *testing.T) { res := NewProcess(t, false, "rm", "2", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - _, err := sb.Runtime().Stat("~/kegs/@local/personal/2", false) - require.Error(t, err, "node 2 directory should be deleted") + require.False(t, fixtureNodeExists(t, sb.Runtime(), "personal", "2"), "node 2 should be deleted") // Node 1: [Project Alpha](../2) → [Project Alpha](../0) - content1 := string(sb.MustReadFile("~/kegs/@local/personal/1/README.md")) + content1 := fixtureContent(t, sb.Runtime(), "personal", "1") require.Contains(t, content1, "../0", "node 1 should redirect stale link to node 0") require.NotContains(t, content1, "../2", "node 1 must not keep stale ref to removed node 2") // Node 3: [Project Alpha](../2) and bare ../2 → both become ../0 - content3 := string(sb.MustReadFile("~/kegs/@local/personal/3/README.md")) + content3 := fixtureContent(t, sb.Runtime(), "personal", "3") require.Contains(t, content3, "../0", "node 3 should redirect stale link to node 0") require.NotContains(t, content3, "../2", "node 3 must not keep stale ref to removed node 2") } @@ -95,10 +93,9 @@ func TestRemoveCommand_RedirectsLinksCreatedViaStdin(t *testing.T) { res = NewProcess(t, false, "rm", "5", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - _, err := sb.Runtime().Stat("~/kegs/@local/personal/5", false) - require.Error(t, err, "node 5 directory should be deleted") + require.False(t, fixtureNodeExists(t, sb.Runtime(), "personal", "5"), "node 5 should be deleted") - content4 := string(sb.MustReadFile("~/kegs/@local/personal/4/README.md")) + content4 := fixtureContent(t, sb.Runtime(), "personal", "4") require.Contains(t, content4, "../0", "references to removed node should point to 0") require.NotContains(t, content4, "../5", "stale ref to removed node must be gone") } @@ -113,12 +110,10 @@ func TestRemoveCommand_MultipleNodes(t *testing.T) { res := NewProcess(t, false, "rm", "2", "3", "--keg", "personal").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) - _, err := sb.Runtime().Stat("~/kegs/@local/personal/2", false) - require.Error(t, err) - _, err = sb.Runtime().Stat("~/kegs/@local/personal/3", false) - require.Error(t, err) + require.False(t, fixtureNodeExists(t, sb.Runtime(), "personal", "2")) + require.False(t, fixtureNodeExists(t, sb.Runtime(), "personal", "3")) - content1 := string(sb.MustReadFile("~/kegs/@local/personal/1/README.md")) + content1 := fixtureContent(t, sb.Runtime(), "personal", "1") require.NotContains(t, content1, "../2") require.NotContains(t, content1, "../3") require.Contains(t, content1, "../0") diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index ac18dd5e..72f433e4 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -38,6 +38,9 @@ type Deps struct { Tap *tapper.Tap Err error + // TapFactory constructs the command's Tap instance. Production leaves this + // nil and uses tapper.NewTap; tests inject repository-independent fixtures. + TapFactory func(tapper.TapOptions) (*tapper.Tap, error) // AuthLoginDeviceFn is the seam through which `tap auth login` drives the // RFC 8628 device authorization grant — the single browser-based login @@ -127,7 +130,11 @@ func NewRootCmd(deps *Deps) *cobra.Command { if err != nil { return err } - tap, err := tapper.NewTap(tapper.TapOptions{ + factory := deps.TapFactory + if factory == nil { + factory = tapper.NewTap + } + tap, err := factory(tapper.TapOptions{ Root: wd, ConfigPath: deps.ConfigPath, Runtime: rt, @@ -257,19 +264,16 @@ func NewRootCmd(deps *Deps) *cobra.Command { return out, cobra.ShellCompDirectiveNoFileComp }) if deps.Profile.withDefaults().AllowKegAliasFlags { - // Keg-resolution flags (global): --keg is the selector (a bare name, an - // @namespace/keg reference, or a path to a local keg); --namespace and - // --hub are component overrides that compose with a bare --keg. The - // disk-discovery selectors (--project/--cwd) are gone — a local keg - // resolves through the namespace chain like a remote one, and a project's - // keg is set once with `tap use`. + // Keg-resolution flags (global): --keg is the selector (a bare name or an + // @namespace/keg reference); --namespace and --hub are component overrides + // that compose with a bare --keg. cmd.PersistentFlags().StringVarP(&deps.KegTargetOptions.Keg, "keg", "k", "", "keg to use: a bare name or an @namespace/keg reference") mustRegisterFlagCompletion(cmd, "keg", kegFlagCompletionFunc(deps)) cmd.PersistentFlags().StringVar(&deps.KegTargetOptions.Namespace, "namespace", "", "namespace to resolve a bare --keg in (overrides defaultNamespace)") mustRegisterFlagCompletion(cmd, "namespace", namespaceFlagCompletionFunc(deps)) cmd.PersistentFlags().StringVar(&deps.KegTargetOptions.Hub, "hub", "", "hub to resolve the keg on (overrides namespace→hub resolution)") mustRegisterFlagCompletion(cmd, "hub", hubFlagCompletionFunc(deps)) - cmd.PersistentFlags().StringVar(&deps.KegTargetOptions.Flight, "flight", "", "flight context for orient/MCP; direct CLI access uses keg auth") + cmd.PersistentFlags().StringVar(&deps.KegTargetOptions.Flight, "flight", "", "flight context for launch/orient/MCP; direct CLI access uses keg auth") mustRegisterFlagCompletion(cmd, "flight", flightFlagCompletionFunc(deps)) // A flight is not a target selector, so it composes with the single-keg // selectors rather than excluding them. Direct CLI commands bypass flight @@ -287,7 +291,6 @@ func NewRootCmd(deps *Deps) *cobra.Command { NewArchiveCmd(deps), NewFileCmd(deps), NewFlightCmd(deps), - NewGraphCmd(deps), NewGrepCmd(deps), NewHubCmd(deps), NewImageCmd(deps), @@ -327,14 +330,6 @@ func NewRootCmd(deps *Deps) *cobra.Command { configCmd, ) } - // IncludeRepoCommand gates the keg-creation surface. `tap keg create` is the - // canonical command (added inside NewKegCmd under the same flag); `tap init` - // remains here as a hidden back-compat alias. - var initCmd *cobra.Command - if deps.Profile.IncludeRepoCommand { - initCmd = newInitCompatCmd(deps) - subcommands = append(subcommands, initCmd) - } cmd.AddCommand(subcommands...) // The top-level `config` command defines its own local --project/--user // flags; strip the inherited keg-target entries (--keg/--namespace/--hub) @@ -342,14 +337,6 @@ func NewRootCmd(deps *Deps) *cobra.Command { if configCmd != nil { filterRepoTargetFlagsInHelp(configCmd) } - // `tap init` re-binds --keg/--project/--path/--cwd locally with - // create-time semantics. Strip the inherited keg-target persistent - // flags from its "Global Flags" help section so users don't see two - // entries for each name. - if initCmd != nil && deps.Profile.withDefaults().AllowKegAliasFlags { - filterRepoTargetFlagsInHelp(initCmd) - } - return cmd } @@ -442,7 +429,11 @@ func completionTap(deps *Deps) (*tapper.Tap, error) { if err != nil { return nil, err } - return tapper.NewTap(tapper.TapOptions{ + factory := deps.TapFactory + if factory == nil { + factory = tapper.NewTap + } + return factory(tapper.TapOptions{ Root: wd, ConfigPath: deps.ConfigPath, Runtime: deps.Runtime, @@ -475,9 +466,6 @@ func completionBareNamespace(rt *toolkit.Runtime, cfg *tapper.Config) string { if ns := strings.TrimSpace(entry.DefaultNamespace); ns != "" { return ns } - if strings.TrimSpace(entry.Kind) == tapper.HubKindLocal { - return tapper.LocalHubName - } } } return "" diff --git a/pkg/cli/cmd_root_flags_test.go b/pkg/cli/cmd_root_flags_test.go index 11d76b7b..364ac18d 100644 --- a/pkg/cli/cmd_root_flags_test.go +++ b/pkg/cli/cmd_root_flags_test.go @@ -76,12 +76,6 @@ func TestTap_DirectCatBypassesFlightCover(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - sb.MustWriteFile("/home/testuser/kegs/flights.d/focused.yaml", []byte(`title: Focused -cover: - - namespace: local - keg: personal - role: viewer -`), 0o644) res := NewProcess(t, false, "cat", "0", "--keg", "work", "--flight", "+focused", "--content-only").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) @@ -92,18 +86,12 @@ func TestTap_DirectCreateBypassesViewerFlightCap(t *testing.T) { t.Parallel() sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - sb.MustWriteFile("/home/testuser/kegs/flights.d/focused.yaml", []byte(`title: Focused -cover: - - namespace: local - keg: personal - role: viewer -`), 0o644) res := NewProcess(t, false, "create", "--keg", "personal", "--flight", "+focused", "--title", "Allowed CLI Write").Run(sb.Context(), sb.Runtime()) require.NoError(t, res.Err) nodeID := strings.TrimSpace(string(res.Stdout)) require.NotEmpty(t, nodeID) - content := string(sb.MustReadFile("/home/testuser/kegs/@local/personal/" + nodeID + "/README.md")) + content := fixtureContent(t, sb.Runtime(), "personal", nodeID) require.Contains(t, content, "# Allowed CLI Write") } @@ -146,13 +134,13 @@ func TestTap_RootPersistentKegFlagNumericShorthandCompletionUsesCat(t *testing.T func TestTap_RootPersistentKegFlagCompletion(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "--keg", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/personal") + require.Contains(t, suggestions, "@team/personal") require.Contains(t, suggestions, "personal") } @@ -168,17 +156,3 @@ func TestTap_KegNamespaceConflict(t *testing.T) { require.Error(t, res.Err) require.Contains(t, string(res.Stderr), "conflicts with the namespace") } - -func TestKegHelp_HidesPersistentKegTargetFlags(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - res := NewKegProcess(t, false, "--help").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - stdout := string(res.Stdout) - // The pruned keg binary exposes no keg-resolution flags. - require.NotContains(t, stdout, "--keg") - require.NotContains(t, stdout, "--namespace") - require.NotContains(t, stdout, "--hub") -} diff --git a/pkg/cli/cmd_root_flight_test.go b/pkg/cli/cmd_root_flight_test.go index 1295605c..386c90e5 100644 --- a/pkg/cli/cmd_root_flight_test.go +++ b/pkg/cli/cmd_root_flight_test.go @@ -2,25 +2,41 @@ package cli import ( "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/jlrickert/tapper/pkg/tapper" "github.com/stretchr/testify/require" ) func TestRootConfiguredFlightDoesNotBecomeExplicitDependency(t *testing.T) { t.Parallel() + flight := tapper.HubFlight{Namespace: "team", Slug: "project", Title: "Project", Instructions: "Project instructions"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/@team/+project": + _ = json.NewEncoder(w).Encode(flight) + case "/api/v1/flights": + _ = json.NewEncoder(w).Encode([]tapper.HubFlight{flight}) + case "/api/v1/kegs": + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() sb := newTestSandbox(t) require.NoError(t, sb.Setwd("/home/testuser/project/child")) require.NoError(t, sb.Runtime().AtomicWriteFile( "/home/testuser/.config/tapper/config.yaml", - []byte("flight: +baseline\nfallbackNamespace: local\nhubs:\n home:\n kind: local\n basePath: /home/testuser/kegs\n"), 0o644)) + []byte(fmt.Sprintf("flight: +baseline\nfallbackHub: home\nfallbackNamespace: team\nhubs:\n home:\n kind: remote\n url: %s\n token: test-token\n", srv.URL)), 0o644)) require.NoError(t, sb.Runtime().AtomicWriteFile( "/home/testuser/project/.tapper/config.yaml", []byte("flight: +project\n"), 0o644)) - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/project.yaml", - []byte("title: Project\ninstructions: Project instructions\n"), 0o644)) - deps := &Deps{Profile: TapProfile(), Runtime: sb.Runtime()} cmd := NewRootCmd(deps) cmd.SetArgs([]string{"orient"}) diff --git a/pkg/cli/cmd_root_graph_removed_test.go b/pkg/cli/cmd_root_graph_removed_test.go new file mode 100644 index 00000000..0b46fc3d --- /dev/null +++ b/pkg/cli/cmd_root_graph_removed_test.go @@ -0,0 +1,24 @@ +package cli + +import ( + "context" + "testing" + + "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/stretchr/testify/require" +) + +func TestGraphCommandRemoved(t *testing.T) { + t.Parallel() + sb := newTestSandbox(t) + profile := TapProfile() + require.False(t, commandNames(t, sb.Runtime(), profile)["graph"]) + + proc := sandbox.NewProcess(func(ctx context.Context, rt *toolkit.Runtime) (int, error) { + return RunWithProfile(ctx, rt, []string{"graph"}, profile) + }, false) + res := proc.Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + require.Contains(t, string(res.Stderr), `unknown command "graph"`) +} diff --git a/pkg/cli/cmd_schema.go b/pkg/cli/cmd_schema.go index d78a08ad..e3a5c94a 100644 --- a/pkg/cli/cmd_schema.go +++ b/pkg/cli/cmd_schema.go @@ -102,6 +102,11 @@ written directly instead of opening an editor.`, RunE: func(cmd *cobra.Command, args []string) error { applyKegTargetProfile(deps, &opts.KegTargetOptions) opts.Type = args[0] + hash, err := deps.Tap.SchemaHash(cmd.Context(), tapper.SchemaOptions{KegTargetOptions: opts.KegTargetOptions, Type: opts.Type}) + if err != nil { + return err + } + opts.ExpectedHash = hash opts.Stream = deps.Runtime.Stream() return deps.Tap.EditSchema(cmd.Context(), opts) }, @@ -120,6 +125,11 @@ func newSchemaRmCmd(deps *Deps) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { applyKegTargetProfile(deps, &opts.KegTargetOptions) opts.Type = args[0] + hash, err := deps.Tap.SchemaHash(cmd.Context(), opts) + if err != nil { + return err + } + opts.ExpectedHash = hash return deps.Tap.DeleteSchema(cmd.Context(), opts) }, } diff --git a/pkg/cli/cmd_schema_test.go b/pkg/cli/cmd_schema_test.go index 5e7089cd..e58a9304 100644 --- a/pkg/cli/cmd_schema_test.go +++ b/pkg/cli/cmd_schema_test.go @@ -8,7 +8,7 @@ import ( "testing" testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/schemas" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -75,7 +75,8 @@ markdown: require.NoError(t, err) require.True(t, strings.HasPrefix(strings.TrimSpace(string(basenameRaw)), "tap-schema-edit-local-example-task-")) opened := string(raw) - require.True(t, strings.HasPrefix(opened, "# yaml-language-server: $schema="+keg.KegSchemaDefinitionSchemaURL+"\n")) + require.True(t, strings.HasPrefix(opened, + schemas.ModelinePrefix+schemas.ModelineURI(sb.Runtime(), schemas.KegSchemaDefinition)+"\n"), "got: %s", opened) require.Contains(t, opened, "type: task") got := readSchemaForCLI(t, sb, "example", "task") diff --git a/pkg/cli/cmd_settings_edit_test.go b/pkg/cli/cmd_settings_edit_test.go index 78078d4c..badb8244 100644 --- a/pkg/cli/cmd_settings_edit_test.go +++ b/pkg/cli/cmd_settings_edit_test.go @@ -45,7 +45,7 @@ EOF res := NewProcess(t, false, "keg", "settings", "edit", "--keg", "example").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - edited := string(sb.MustReadFile("~/kegs/@local/example/keg")) + edited := fixtureSettings(t, sb.Runtime(), "example") require.Contains(t, edited, "title: Edited Title") require.Contains(t, edited, "entities:") require.Contains(t, edited, "custom_block:") @@ -79,12 +79,12 @@ EOF require.NoError(t, sb.Runtime().Set("EDITOR", "/bin/sh "+scriptPath)) sb.Runtime().Unset("VISUAL") - before := sb.MustReadFile("~/kegs/@local/example/keg") + before := []byte(fixtureSettings(t, sb.Runtime(), "example")) res := NewProcess(t, false, "keg", "settings", "edit", "--keg", "example").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "keg config is invalid after editing") + require.Contains(t, string(res.Stderr), "keg settings is invalid after editing") - after := sb.MustReadFile("~/kegs/@local/example/keg") + after := []byte(fixtureSettings(t, sb.Runtime(), "example")) require.Equal(t, string(before), string(after)) } @@ -108,7 +108,7 @@ summary: piped content res := NewProcess(t, false, "keg", "settings", "edit", "--keg", "example").RunWithIO(sb.Context(), sb.Runtime(), stdin) require.NoError(t, res.Err) - saved := string(sb.MustReadFile("~/kegs/@local/example/keg")) + saved := fixtureSettings(t, sb.Runtime(), "example") require.Contains(t, saved, "title: Final Title") require.Contains(t, saved, "summary: piped content") } @@ -126,13 +126,13 @@ func TestSettingsEdit_RejectsInvalidPipedStdin(t *testing.T) { require.NoError(t, sb.Runtime().Set("EDITOR", "/bin/false")) sb.Runtime().Unset("VISUAL") - before := sb.MustReadFile("~/kegs/@local/example/keg") + before := []byte(fixtureSettings(t, sb.Runtime(), "example")) stdin := strings.NewReader("kegv: [\n") res := NewProcess(t, false, "keg", "settings", "edit", "--keg", "example").RunWithIO(sb.Context(), sb.Runtime(), stdin) require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "keg config from stdin is invalid") + require.Contains(t, string(res.Stderr), "keg settings from stdin is invalid") - after := sb.MustReadFile("~/kegs/@local/example/keg") + after := []byte(fixtureSettings(t, sb.Runtime(), "example")) require.Equal(t, string(before), string(after)) } @@ -166,7 +166,7 @@ EOF res := NewProcess(t, false, "keg", "settings", "edit", "--keg", "example").RunWithIO(sb.Context(), sb.Runtime(), strings.NewReader("")) require.NoError(t, res.Err) - saved := string(sb.MustReadFile("~/kegs/@local/example/keg")) + saved := fixtureSettings(t, sb.Runtime(), "example") require.Contains(t, saved, "title: First Valid Config") require.Contains(t, saved, "summary: saved once") } diff --git a/pkg/cli/cmd_settings_test.go b/pkg/cli/cmd_settings_test.go index c73330a4..8c6fe64a 100644 --- a/pkg/cli/cmd_settings_test.go +++ b/pkg/cli/cmd_settings_test.go @@ -1,9 +1,11 @@ package cli_test import ( + "context" "testing" testutils "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/pkg/keg" "github.com/stretchr/testify/require" ) @@ -57,32 +59,10 @@ func TestSettingsCommand_DisplaysKegMetadata(t *testing.T) { } func TestSettingsCommand_IntegrationWithInit(t *testing.T) { - t.Run("config_after_init_displays_keg_metadata", func(innerT *testing.T) { - innerT.Parallel() - opts := []testutils.Option{ - testutils.WithFixture("testuser", "~"), - } - sb := NewSandbox(innerT, opts...) - - // First, initialize a user keg - initCmd := NewProcess(innerT, false, - "init", - "--user", - "--keg", "newstudy", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "init should succeed") - - // Now display the keg config - infoCmd := NewProcess(innerT, false, "keg", "settings", "--keg", "newstudy") - infoRes := infoCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, infoRes.Err, "settings should succeed after init") - - stdout := string(infoRes.Stdout) - require.Contains(innerT, stdout, "kegv:", "output should contain keg version") - require.Contains(innerT, stdout, "creator:", "output should contain creator field") - }) + sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) + res := NewProcess(t, false, "init").Run(sb.Context(), sb.Runtime()) + require.Error(t, res.Err) + require.Contains(t, string(res.Stderr), `unknown command "init"`) } func TestSettingsCommand_WithJoeFixture(t *testing.T) { @@ -148,7 +128,12 @@ tags: custom_block: enabled: true ` - sb.MustWriteFile("~/kegs/@local/example/keg", []byte(custom), 0o644) + opened := fixtureKeg(t, sb.Runtime(), "example") + current, err := opened.Settings(context.Background()) + require.NoError(t, err) + require.NoError(t, opened.SetSettings(context.Background(), []byte(custom), keg.SettingsWriteOptions{ + ExpectedHash: current.Hash(), + })) infoCmd := NewProcess(t, false, "keg", "settings", "--keg", "example") infoRes := infoCmd.Run(sb.Context(), sb.Runtime()) diff --git a/pkg/cli/cmd_snapshot_test.go b/pkg/cli/cmd_snapshot_test.go deleted file mode 100644 index dea2fd9c..00000000 --- a/pkg/cli/cmd_snapshot_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package cli_test - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "io" - "strings" - "testing" - "time" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -func TestKegSnapshotHistoryAndRestore(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - res := NewKegProcess(t, false, "snapshot", "create", "1", "-m", "before change").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Equal(t, "1\n", string(res.Stdout)) - - sb.MustWriteFile("~/kegs/@local/personal/1/README.md", []byte("# Personal Overview\n\nUpdated snapshot body.\n\n- [Project Alpha](../2)\n- [Meeting Notes](../3)\n"), 0o644) - - res = NewKegProcess(t, false, "index", "rebuild").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - res = NewKegProcess(t, false, "snapshot", "create", "1", "-m", "after change").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Equal(t, "2\n", string(res.Stdout)) - - res = NewKegProcess(t, false, "snapshot", "history", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - stdout := string(res.Stdout) - require.Contains(t, stdout, "before change") - require.Contains(t, stdout, "after change") - - res = NewKegProcess(t, false, "snapshot", "view", "1", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "An index of personal notes and projects.") - require.NotContains(t, string(res.Stdout), "Updated snapshot body.") - - res = NewKegProcess(t, false, "snapshot", "restore", "1", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - res = NewKegProcess(t, false, "cat", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "An index of personal notes and projects.") - require.NotContains(t, string(res.Stdout), "Updated snapshot body.") - - res = NewKegProcess(t, false, "snapshot", "history", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "restore from rev 1") -} - -func TestKegArchiveImportOverwritesExistingNodes(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - res := NewKegProcess(t, false, "snapshot", "create", "1", "-m", "before export").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - exportPath := "~/export.keg.tar.gz" - res = NewKegProcess(t, false, "archive", "export", "--nodes", "1,2,3", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "export.keg.tar.gz") - - targetRepo := keg.NewFsRepo("~/import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - id, err := targetKeg.Create(sb.Context(), &keg.CreateOptions{Title: "Existing node"}) - require.NoError(t, err) - require.Equal(t, keg.NodeId{ID: 1}, id.ID) - _, err = targetKeg.AppendSnapshot(sb.Context(), id.ID, "old target") - require.NoError(t, err) - require.NoError(t, targetRepo.WriteFile(sb.Context(), id.ID, "keep.txt", []byte("keep me"))) - require.NoError(t, sb.Runtime().Setwd("~/import-target")) - - res = NewKegProcess(t, false, "archive", "import", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - lines := strings.Fields(string(res.Stdout)) - require.Equal(t, []string{"1", "2", "3"}, lines) - - res = NewKegProcess(t, false, "cat", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - stdout := string(res.Stdout) - require.Contains(t, stdout, "Personal Overview") - require.NotContains(t, stdout, "Existing node") - - hasNode4, err := targetRepo.HasNode(sb.Context(), keg.NodeId{ID: 4}) - require.NoError(t, err) - require.False(t, hasNode4) - - res = NewKegProcess(t, false, "snapshot", "history", "1").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "before export") - require.NotContains(t, string(res.Stdout), "old target") - - asset, err := targetRepo.ReadFile(sb.Context(), id.ID, "keep.txt") - require.NoError(t, err) - require.Equal(t, "keep me", string(asset)) -} - -func TestTapSnapshotArchiveCommandsWithAliasAndPath(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - res := NewProcess(t, false, "snapshot", "create", "1", "--keg", "personal", "-m", "tap snapshot").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Equal(t, "1\n", string(res.Stdout)) - - res = NewProcess(t, false, "snapshot", "history", "1", "--keg", "personal").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "tap snapshot") - - exportPath := "~/tap-export.keg.tar.gz" - res = NewProcess(t, false, "archive", "export", "--keg", "personal", "--nodes", "1", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "tap-export.keg.tar.gz") - - targetRepo := keg.NewFsRepo("~/tap-import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - - res = NewProcess(t, false, "archive", "import", exportPath, "--keg", "~/tap-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Equal(t, "1\n", string(res.Stdout)) - - res = NewProcess(t, false, "cat", "1", "--keg", "~/tap-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "Personal Overview") - - res = NewProcess(t, false, "snapshot", "history", "1", "--keg", "~/tap-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "tap snapshot") -} - -func TestArchiveImportPreservesSnapshotTimestamps(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - sourceRepo := keg.NewFsRepo("~/kegs/@local/personal", sb.Runtime()) - nodeID := keg.NodeId{ID: 1} - - res := NewProcess(t, false, "snapshot", "create", "1", "--keg", "personal", "-m", "baseline").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - sourceHistory, err := sourceRepo.ListSnapshots(sb.Context(), nodeID) - require.NoError(t, err) - require.Len(t, sourceHistory, 1) - - sb.Advance(45 * time.Minute) - sb.MustWriteFile("~/kegs/@local/personal/1/README.md", []byte("# Personal Overview\n\nTimestamp preservation update.\n\n- [Project Alpha](../2)\n"), 0o644) - - res = NewProcess(t, false, "index", "rebuild", "--keg", "personal").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - res = NewProcess(t, false, "snapshot", "create", "1", "--keg", "personal", "-m", "updated").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - sourceHistory, err = sourceRepo.ListSnapshots(sb.Context(), nodeID) - require.NoError(t, err) - require.Len(t, sourceHistory, 2) - - exportPath := "~/timestamp-history.keg.tar.gz" - res = NewProcess(t, false, "archive", "export", "--keg", "personal", "--nodes", "1", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - sb.Advance(4 * time.Hour) - - targetRepo := keg.NewFsRepo("~/timestamp-import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - - res = NewProcess(t, false, "archive", "import", exportPath, "--keg", "~/timestamp-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - importedHistory, err := targetRepo.ListSnapshots(sb.Context(), nodeID) - require.NoError(t, err) - require.Len(t, importedHistory, len(sourceHistory)) - - for i := range sourceHistory { - require.True(t, importedHistory[i].CreatedAt.Equal(sourceHistory[i].CreatedAt)) - require.Equal(t, sourceHistory[i].Message, importedHistory[i].Message) - } - require.False(t, importedHistory[0].CreatedAt.Equal(sb.Now())) -} - -func TestArchiveCommandsRoundTripSchemas(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - ctx := sb.Context() - rt := sb.Runtime() - - sourceRepo := keg.NewFsRepo("~/schema-archive-source", rt) - sourceKeg := keg.NewLocalKeg(sourceRepo, rt) - require.NoError(t, sourceKeg.Init(ctx)) - sourceSchema := `type: task -summary: Archived tasks -meta: - type: object - required: ["type"] - properties: - type: - const: task -markdown: - requireTitle: true -` - require.NoError(t, sourceKeg.WriteSchema(ctx, "task", []byte(sourceSchema))) - require.NoError(t, sourceKeg.SetContent(ctx, keg.NodeId{ID: 0}, []byte("---\ntype: task\n---\n# Zero\n"))) - _, err := sourceKeg.Create(ctx, &keg.CreateOptions{ - Schema: "task", - Body: []byte("---\ntype: task\n---\n# CLI Imported Task\n"), - }) - require.NoError(t, err) - - targetRepo := keg.NewFsRepo("~/schema-archive-target", rt) - targetKeg := keg.NewLocalKeg(targetRepo, rt) - require.NoError(t, targetKeg.Init(ctx)) - require.NoError(t, targetKeg.WriteSchema(ctx, "task", []byte("type: task\nsummary: Target tasks\n"))) - require.NoError(t, targetKeg.WriteSchema(ctx, "decision", []byte("type: decision\nsummary: Target-only decisions\n"))) - - exportPath := "~/schema-roundtrip.keg.tar.gz" - res := NewProcess(t, false, "archive", "export", "--keg", "~/schema-archive-source", "-o", exportPath).Run(ctx, rt) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "schema-roundtrip.keg.tar.gz") - - res = NewProcess(t, false, "archive", "import", exportPath, "--keg", "~/schema-archive-target").Run(ctx, rt) - require.NoError(t, res.Err) - - res = NewProcess(t, false, "schema", "get", "--keg", "~/schema-archive-target", "task").Run(ctx, rt) - require.NoError(t, res.Err) - require.Equal(t, sourceSchema, string(res.Stdout)) - - res = NewProcess(t, false, "schema", "get", "--keg", "~/schema-archive-target", "decision").Run(ctx, rt) - require.NoError(t, res.Err) - require.Contains(t, string(res.Stdout), "Target-only decisions") -} - -func TestRootCompletionSuggestsSnapshotArchiveCommands(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - comp := NewCompletionProcess(t, false, 0, "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - - suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "snapshot") - require.Contains(t, suggestions, "archive") - require.Contains(t, suggestions, "import") - require.NotContains(t, suggestions, "node") - require.NotContains(t, suggestions, "export") -} - -func TestSnapshotCommand_SuggestsCreateHistoryAndRestore(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - comp := NewCompletionProcess(t, false, 0, "snapshot", "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - - suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "create") - require.Contains(t, suggestions, "history") - require.Contains(t, suggestions, "view") - require.Contains(t, suggestions, "restore") -} - -func TestArchiveCommand_SuggestsImportAndExport(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - comp := NewCompletionProcess(t, false, 0, "archive", "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - - suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "import") - require.Contains(t, suggestions, "export") -} - -func TestArchiveImportCommand_CompletionUsesFileDirective(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t) - - comp := NewCompletionProcess(t, false, 0, "archive", "import", "").Run(sb.Context(), sb.Runtime()) - require.NoError(t, comp.Err) - require.Contains(t, string(comp.Stdout), ":0") -} - -func TestArchiveImportCommand_MissingArchiveShowsResolvedPath(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - - res := NewProcess(t, false, "archive", "import", "~/Downloads/does-not-exist.keg.tar.gz", "--keg", "personal").Run(sb.Context(), sb.Runtime()) - require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "archive not found:") - require.Contains(t, string(res.Stderr), "/home/testuser/Downloads/does-not-exist.keg.tar.gz") -} - -func TestArchiveImportCommand_AcceptsPlainTarArchive(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - exportPath := "~/plain-export.keg.tar.gz" - res := NewProcess(t, false, "archive", "export", "--keg", "personal", "--nodes", "1", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - gzData := sb.MustReadFile(exportPath) - gzr, err := gzip.NewReader(bytes.NewReader(gzData)) - require.NoError(t, err) - tarData, err := io.ReadAll(gzr) - require.NoError(t, err) - require.NoError(t, gzr.Close()) - - plainTarPath := "~/plain-export-tar.keg.tar.gz" - sb.MustWriteFile(plainTarPath, tarData, 0o644) - - targetRepo := keg.NewFsRepo("~/plain-import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - - res = NewProcess(t, false, "archive", "import", plainTarPath, "--keg", "~/plain-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.Equal(t, "1\n", string(res.Stdout)) -} - -func TestArchiveExportCommand_NoHistoryOmitsSnapshots(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - res := NewProcess(t, false, "snapshot", "create", "1", "--keg", "personal", "-m", "before export").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - exportPath := "~/no-history.keg.tar.gz" - res = NewProcess(t, false, "archive", "export", "--keg", "personal", "--nodes", "1", "--no-history", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - targetRepo := keg.NewFsRepo("~/no-history-import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - - res = NewProcess(t, false, "archive", "import", exportPath, "--keg", "~/no-history-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - res = NewProcess(t, false, "snapshot", "history", "1", "--keg", "~/no-history-import-target").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - require.NotContains(t, string(res.Stdout), "before export") -} - -func TestArchiveImportCommand_FailsWhenHistoryIndexMissing(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, - testutils.WithFixture("joe", "~"), - testutils.WithWd("~/kegs/@local/personal"), - ) - - res := NewProcess(t, false, "snapshot", "create", "1", "--keg", "personal", "-m", "before export").Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - exportPath := "~/broken-history.keg.tar.gz" - res = NewProcess(t, false, "archive", "export", "--keg", "personal", "--nodes", "1", "-o", exportPath).Run(sb.Context(), sb.Runtime()) - require.NoError(t, res.Err) - - broken := dropArchivePath(t, sb.MustReadFile(exportPath), "keg-archive/nodes/1/snapshots/index.json") - brokenPath := "~/broken-history-missing-index.keg.tar.gz" - sb.MustWriteFile(brokenPath, broken, 0o644) - - targetRepo := keg.NewFsRepo("~/broken-history-import-target", sb.Runtime()) - targetKeg := keg.NewLocalKeg(targetRepo, sb.Runtime()) - require.NoError(t, targetKeg.Init(sb.Context())) - DisableStrictSchemaPolicy(t, sb.Context(), targetKeg) - - res = NewProcess(t, false, "archive", "import", brokenPath, "--keg", "~/broken-history-import-target").Run(sb.Context(), sb.Runtime()) - require.Error(t, res.Err) - require.Contains(t, string(res.Stderr), "missing snapshots/index.json") -} - -func dropArchivePath(t *testing.T, archive []byte, dropPath string) []byte { - t.Helper() - - gzr, err := gzip.NewReader(bytes.NewReader(archive)) - require.NoError(t, err) - defer gzr.Close() - - var raw bytes.Buffer - tr := tar.NewReader(gzr) - gzw := gzip.NewWriter(&raw) - tw := tar.NewWriter(gzw) - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - require.NoError(t, err) - payload, err := io.ReadAll(tr) - require.NoError(t, err) - if header.Name == dropPath { - continue - } - - copyHeader := *header - copyHeader.Size = int64(len(payload)) - require.NoError(t, tw.WriteHeader(©Header)) - _, err = tw.Write(payload) - require.NoError(t, err) - } - - require.NoError(t, tw.Close()) - require.NoError(t, gzw.Close()) - return raw.Bytes() -} diff --git a/pkg/cli/cmd_stats_test.go b/pkg/cli/cmd_stats_test.go index ab948ba7..725f456a 100644 --- a/pkg/cli/cmd_stats_test.go +++ b/pkg/cli/cmd_stats_test.go @@ -29,7 +29,7 @@ func TestStatsCommand_TableDriven(t *testing.T) { name: "missing_alias", args: []string{"stats", "0", "--keg", "missing"}, fixture: strPtr("joe"), - expectedErr: "node 0 not found", + expectedErr: "keg not initialized", }, { name: "missing_node", diff --git a/pkg/cli/cmd_use_test.go b/pkg/cli/cmd_use_test.go index 704d2c4b..4138ceee 100644 --- a/pkg/cli/cmd_use_test.go +++ b/pkg/cli/cmd_use_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - testutils "github.com/jlrickert/cli-toolkit/sandbox" "github.com/jlrickert/tapper/pkg/tapper" "github.com/spf13/cobra" "github.com/stretchr/testify/require" @@ -60,34 +59,31 @@ func TestUse_BarePositionalStillSetsKeg(t *testing.T) { func TestUseCompletion_SuggestsKegsAndFlights(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - sb.MustWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: Backend\n"), 0o644) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "use", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) suggestions := parseCompletionSuggestions(string(comp.Stdout)) - require.Contains(t, suggestions, "@local/personal") + require.Contains(t, suggestions, "@team/personal") require.Contains(t, suggestions, "personal") - require.Contains(t, suggestions, "@local/+backend") + require.Contains(t, suggestions, "@team/+backend") require.Contains(t, string(comp.Stdout), fmt.Sprintf(":%d", cobra.ShellCompDirectiveNoFileComp)) } func TestUseCompletion_FiltersFlightsByPrefix(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - sb.MustWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: Backend\n"), 0o644) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) - comp := NewCompletionProcess(t, false, 0, "use", "@local/+").Run(sb.Context(), sb.Runtime()) + comp := NewCompletionProcess(t, false, 0, "use", "@team/+back").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) - require.Equal(t, []string{"@local/+backend"}, parseCompletionSuggestions(string(comp.Stdout))) + require.Equal(t, []string{"@team/+backend"}, parseCompletionSuggestions(string(comp.Stdout))) } func TestUseCompletion_StopsAfterOneArg(t *testing.T) { t.Parallel() - sb := NewSandbox(t, testutils.WithFixture("joe", "~")) - sb.MustWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: Backend\n"), 0o644) + sb := NewRemoteKegListSandbox(t, remoteCompletionKegs()) comp := NewCompletionProcess(t, false, 0, "use", "personal", "").Run(sb.Context(), sb.Runtime()) require.NoError(t, comp.Err) diff --git a/pkg/cli/cmd_watch.go b/pkg/cli/cmd_watch.go index a582e55f..569045d4 100644 --- a/pkg/cli/cmd_watch.go +++ b/pkg/cli/cmd_watch.go @@ -39,9 +39,8 @@ Each change to the node prints one line describing what changed: the event kind (created, modified, deleted), the node ID, and the affected field (content, meta, stats). Access events are suppressed unless --all is given. -For filesystem kegs the command watches the node directory; for hub kegs it -subscribes to the hub's live event stream, so saves made by the web UI or by -other tap instances appear here as they happen. +The command subscribes to the hub's live event stream, so saves made by the +web UI or by other tap instances appear here as they happen. Use --json for newline-delimited JSON suitable for scripting, --count to exit after a fixed number of events, and --timeout to exit after a duration.`, diff --git a/pkg/cli/cmd_watch_test.go b/pkg/cli/cmd_watch_test.go index 399f4992..495d59da 100644 --- a/pkg/cli/cmd_watch_test.go +++ b/pkg/cli/cmd_watch_test.go @@ -57,7 +57,7 @@ func TestWatchCommand_TimeoutWithNoEvents(t *testing.T) { } // TestWatchCommand_EmitsEventOnContentChange runs the watch in the background -// and modifies the node's README.md until the watcher reports the change. +// and mutates the test repository until the watcher reports the change. func TestWatchCommand_EmitsEventOnContentChange(t *testing.T) { t.Parallel() sb := NewSandbox(t, tu.WithFixture("joe", "~")) @@ -71,9 +71,8 @@ func TestWatchCommand_EmitsEventOnContentChange(t *testing.T) { }() // The watcher needs a moment to register before writes are observable. - // Keep writing until the watch exits (or times out); each write changes - // the content so fsnotify fires. - contentPath := "~/kegs/@local/personal/0/README.md" + // Keep writing until the watch exits (or times out); each repository write + // emits a node event. var res *tu.ProcessResult deadline := time.After(20 * time.Second) i := 0 @@ -87,7 +86,7 @@ loop: case <-time.After(200 * time.Millisecond): i++ body := "# Watch Test\n\nrevision " + strings.Repeat("x", i) + "\n" - sb.MustWriteFile(contentPath, []byte(body), 0o644) + fixtureSetContent(t, sb.Runtime(), "personal", "0", body) } } diff --git a/pkg/cli/keg_target_flags.go b/pkg/cli/keg_target_flags.go index 680607f4..e52773ef 100644 --- a/pkg/cli/keg_target_flags.go +++ b/pkg/cli/keg_target_flags.go @@ -19,29 +19,13 @@ func applyKegTargetProfile(deps *Deps, opts *tapper.KegTargetOptions) { if opts.Hub == "" { opts.Hub = deps.KegTargetOptions.Hub } - if !opts.Project { - opts.Project = deps.KegTargetOptions.Project - } - if opts.Path == "" { - opts.Path = deps.KegTargetOptions.Path - } - if !opts.Cwd { - opts.Cwd = deps.KegTargetOptions.Cwd - } if opts.Flight == "" { opts.Flight = deps.KegTargetOptions.Flight } // Direct CLI commands use normal keg auth and keep Flight only as context // for surfaces such as orient. MCP receives deps.KegTargetOptions directly. opts.BypassFlightRestrictions = true - profile := deps.Profile.withDefaults() - if profile.ForceProjectResolution { - opts.Project = true - } - // The full `tap` surface requires `tap bootstrap` before config-driven keg - // resolution; the pruned `keg` binary (no config command) stays exempt and - // resolves project-local kegs without setup. - if profile.IncludeConfigCommand { + if deps.Profile.withDefaults().IncludeConfigCommand { opts.RequireBootstrap = true } } diff --git a/pkg/cli/profile.go b/pkg/cli/profile.go index c3a9d39a..34a1579f 100644 --- a/pkg/cli/profile.go +++ b/pkg/cli/profile.go @@ -5,19 +5,12 @@ type Profile struct { // Use is the root command name shown in help. Use string - // ForceProjectResolution makes node operations resolve only against - // project-local kegs. - ForceProjectResolution bool - // AllowKegAliasFlags enables alias-based selection flags such as --keg. AllowKegAliasFlags bool // IncludeConfigCommand enables the config command tree. IncludeConfigCommand bool - // IncludeRepoCommand enables the repo command tree. - IncludeRepoCommand bool - // IncludeIntegrations enables host plugin installation and the hidden // host-facing hook protocol. These commands belong only to the full tap // binary because installed plugins invoke tap directly. @@ -26,23 +19,10 @@ type Profile struct { func TapProfile() Profile { return Profile{ - Use: "tap", - ForceProjectResolution: false, - AllowKegAliasFlags: true, - IncludeConfigCommand: true, - IncludeRepoCommand: true, - IncludeIntegrations: true, - } -} - -func KegProfile() Profile { - return Profile{ - Use: "keg", - ForceProjectResolution: true, - AllowKegAliasFlags: false, - IncludeConfigCommand: false, - IncludeRepoCommand: false, - IncludeIntegrations: false, + Use: "tap", + AllowKegAliasFlags: true, + IncludeConfigCommand: true, + IncludeIntegrations: true, } } diff --git a/pkg/cli/profile_resolve_test.go b/pkg/cli/profile_resolve_test.go deleted file mode 100644 index 136a180b..00000000 --- a/pkg/cli/profile_resolve_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package cli_test - -import ( - "testing" - - testutils "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -func TestTap_ProjectResolutionFlags(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - initCmd := NewProcess(t, false, - "init", - "--project", - "--cwd", - "--keg", "project", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, initRes.Err, "project init should succeed") - _ = sb.MustReadFile("~/kegs/project/keg") - projectKeg := keg.NewLocalKeg(keg.NewFsRepo("~/kegs/project", sb.Runtime()), sb.Runtime()) - DisableStrictSchemaPolicy(t, sb.Context(), projectKeg) - - createCmd := NewProcess(t, false, - "create", - "--keg", "~/kegs/project", - "--title", "Project Local Note", - ) - createRes := createCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, createRes.Err, "create against the local keg path should succeed") - require.Contains(t, string(createRes.Stdout), "1", "expected node id output") - - catCmd := NewProcess(t, false, - "cat", "1", - "--keg", "~/kegs/project", - ) - catRes := catCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, catRes.Err, "cat with --keg should resolve the local keg") - require.Contains(t, string(catRes.Stdout), "# Project Local Note") - require.NotContains(t, string(catRes.Stdout), "access_count:") -} - -// TestTap_ResolvesProjectKegUnderKegsDir verifies that a project-local keg -// initialized under /kegs// is resolvable via project-target -// resolution (--cwd). Under the namespace-centric model a bare --keg -// routes to the local hub (fallbackNamespace: local) rather than the project -// tree, so project kegs are reached through the project-target flags. -func TestTap_ResolvesProjectKegUnderKegsDir(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - sb.Setwd("~/myproject") - - initCmd := NewProcess(t, false, - "init", - "--project", - "--cwd", - "--keg", "tapper", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, initRes.Err, "project init should succeed") - _ = sb.MustReadFile("~/myproject/kegs/tapper/keg") - - catCmd := NewProcess(t, false, - "cat", "0", - "--keg", "~/myproject/kegs/tapper", - ) - catRes := catCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, catRes.Err, "cat with --keg should resolve the keg under kegs/") - require.Contains(t, string(catRes.Stdout), "# Sorry, planned but not yet available") -} - -func TestKeg_UsesProjectKegOnly(t *testing.T) { - t.Run("errors_when_project_keg_missing", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - h := NewKegProcess(innerT, false, "cat", "0") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "project keg not found") - }) - - t.Run("does_not_fallback_to_legacy_docs_keg", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - legacyInit := NewProcess(innerT, false, - "init", - "--project", - "--path", "~/docs", - "--keg", "legacy", - "--creator", "test-user", - ) - legacyRes := legacyInit.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, legacyRes.Err, "legacy docs keg init should succeed") - _ = sb.MustReadFile("~/docs/keg") - - h := NewKegProcess(innerT, false, "cat", "0") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "project keg not found") - }) - - t.Run("resolves_local_project_keg", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - initCmd := NewProcess(innerT, false, - "init", - "--project", - "--cwd", - "--keg", "project", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(innerT, initRes.Err, "project init should succeed") - _ = sb.MustReadFile("~/kegs/project/keg") - - h := NewKegProcess(innerT, false, "cat", "0") - res := h.Run(sb.Context(), sb.Runtime()) - - require.NoError(innerT, res.Err) - require.Contains(innerT, string(res.Stdout), "# Sorry, planned but not yet available") - require.NotContains(innerT, string(res.Stdout), "access_count:") - }) - - t.Run("does_not_expose_keg_alias_flag", func(innerT *testing.T) { - innerT.Parallel() - sb := NewSandbox(innerT, testutils.WithFixture("testuser", "~")) - - h := NewKegProcess(innerT, false, "cat", "0", "--keg", "example") - res := h.Run(sb.Context(), sb.Runtime()) - - require.Error(innerT, res.Err) - require.Contains(innerT, string(res.Stderr), "unknown flag: --keg") - }) -} - -func TestTap_CwdStandaloneResolution(t *testing.T) { - t.Parallel() - - sb := NewSandbox(t, testutils.WithFixture("testuser", "~")) - sb.Setwd("~") - - initCmd := NewProcess(t, false, - "init", - "--cwd", - "--keg", "project", - "--creator", "test-user", - ) - initRes := initCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, initRes.Err, "init with --cwd should succeed") - _ = sb.MustReadFile("~/kegs/project/keg") - - catCmd := NewProcess(t, false, - "cat", "0", - "--keg", "~/kegs/project", - ) - catRes := catCmd.Run(sb.Context(), sb.Runtime()) - require.NoError(t, catRes.Err, "cat with --keg should resolve the local keg") - require.Contains(t, string(catRes.Stdout), "# Sorry, planned but not yet available") -} diff --git a/pkg/cli/testhelpers_test.go b/pkg/cli/testhelpers_test.go index 9dc04b1a..ac25c4a0 100644 --- a/pkg/cli/testhelpers_test.go +++ b/pkg/cli/testhelpers_test.go @@ -3,13 +3,22 @@ package cli_test import ( "context" "embed" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "sort" "strings" + "sync" "testing" tu "github.com/jlrickert/cli-toolkit/sandbox" "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/internal/testkegrepo" "github.com/jlrickert/tapper/pkg/cli" "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" ) // NOTE: Production code should call streams.IsStdoutTTY() (method) instead of @@ -22,17 +31,91 @@ import ( //go:embed all:data/** var testdata embed.FS +func strPtr(value string) *string { return &value } + func NewSandbox(t *testing.T, opts ...tu.Option) *tu.Sandbox { - return tu.NewSandbox(t, &tu.Options{ + sb := tu.NewSandbox(t, &tu.Options{ Data: testdata, Home: "/home/testuser", User: "testuser", }, opts...) + normalizeFixtureConfig(t, sb.Runtime()) + return sb +} + +func normalizeFixtureConfig(t *testing.T, rt *toolkit.Runtime) { + t.Helper() + home, err := rt.GetHome() + if err != nil { + return + } + path := filepath.Join(home, ".config", "tapper", "config.yaml") + raw, err := rt.ReadFile(path) + if err != nil || !strings.Contains(string(raw), "kind: local") { + return + } + body := strings.ReplaceAll(string(raw), "kind: local", "kind: remote") + body = strings.ReplaceAll(body, " basePath: ~/kegs", " url: https://fixture.invalid\n token: test-token") + if !strings.Contains(body, "fallbackHub:") { + body += "fallbackHub: home\n" + } + if !strings.Contains(body, "namespaces:") { + body += "namespaces:\n local:\n hub: home\n" + } + if err := rt.AtomicWriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("normalize fixture config: %v", err) + } +} + +func NewRemoteKegListSandbox(t *testing.T, kegs []tapper.HubKeg) *tu.Sandbox { + t.Helper() + flights := []tapper.HubFlight{ + {Namespace: "team", Slug: "backend", Title: "Backend", Visibility: "private", Instructions: "Backend instructions"}, + {Namespace: "team", Slug: "baseline", Title: "Baseline", Visibility: "private", Instructions: "Baseline instructions"}, + {Namespace: "team", Slug: "project", Title: "Project", Visibility: "private", Instructions: "Project instructions"}, + {Namespace: "team", Slug: "environment", Title: "Environment", Visibility: "private", Instructions: "Environment instructions"}, + {Namespace: "team", Slug: "explicit", Title: "Explicit", Visibility: "private", Instructions: "Explicit instructions"}, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/v1/kegs": + _ = json.NewEncoder(w).Encode(kegs) + case r.URL.Path == "/api/v1/flights": + _ = json.NewEncoder(w).Encode(flights) + case strings.HasPrefix(r.URL.Path, "/api/v1/@team/+"): + slug := strings.TrimPrefix(r.URL.Path, "/api/v1/@team/+") + for _, flight := range flights { + if flight.Slug == slug { + _ = json.NewEncoder(w).Encode(flight) + return + } + } + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + sb := NewSandbox(t) + sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(fmt.Sprintf(`fallbackHub: test +fallbackNamespace: team +hubs: + test: + kind: remote + url: %s + token: test-token +`, srv.URL)), 0o644) + return sb } func DisableStrictSchemaPolicy(t *testing.T, ctx context.Context, k *keg.LocalKeg) { t.Helper() - if err := k.UpdateConfig(ctx, func(cfg *keg.Config) { + if err := k.UpdateSettings(ctx, func(cfg *keg.Settings) { if cfg.SchemaPolicy == nil { cfg.SchemaPolicy = &keg.SchemaPolicy{} } @@ -47,24 +130,314 @@ func NewCliRunner(t *testing.T) *tu.Process { } func NewProcess(t *testing.T, isTTY bool, args ...string) *tu.Process { + var mu sync.Mutex + var currentRuntime *toolkit.Runtime + var factory func(tapper.TapOptions) (*tapper.Tap, error) return tu.NewProcess(func(ctx context.Context, rt *toolkit.Runtime) (int, error) { + mu.Lock() + if currentRuntime != rt { + currentRuntime = rt + factory = newFixtureTapFactory(t, ctx, rt) + } + activeFactory := factory + mu.Unlock() + ctx = cli.WithTestDepsHook(ctx, func(deps *cli.Deps) { deps.TapFactory = activeFactory }) return cli.Run(ctx, rt, args) }, isTTY) } -func NewKegProcess(t *testing.T, isTTY bool, args ...string) *tu.Process { - return tu.NewProcess(func(ctx context.Context, rt *toolkit.Runtime) (int, error) { - return cli.RunWithProfile(ctx, rt, args, cli.KegProfile()) - }, isTTY) -} - func NewCompletionProcess(t *testing.T, isTTY bool, pos int, words ...string) *tu.Process { _ = pos + var mu sync.Mutex + var currentRuntime *toolkit.Runtime + var factory func(tapper.TapOptions) (*tapper.Tap, error) return tu.NewProcess(func(ctx context.Context, rt *toolkit.Runtime) (int, error) { + mu.Lock() + if currentRuntime != rt { + currentRuntime = rt + factory = newFixtureTapFactory(t, ctx, rt) + } + activeFactory := factory + mu.Unlock() + ctx = cli.WithTestDepsHook(ctx, func(deps *cli.Deps) { deps.TapFactory = activeFactory }) return cli.RunCompletion(ctx, rt, words) }, isTTY) } +type fixtureTapState struct { + mu sync.Mutex + kegs map[string]keg.Keg +} + +var fixtureTapStates sync.Map + +func fixtureStateKey(rt *toolkit.Runtime) string { + if jail := rt.GetJail(); jail != "" { + if resolved, err := filepath.EvalSymlinks(jail); err == nil { + jail = resolved + } + return jail + } + return fmt.Sprintf("runtime:%p", rt) +} + +func fixtureKeg(t *testing.T, rt *toolkit.Runtime, alias string) keg.Keg { + t.Helper() + stored, ok := fixtureTapStates.Load(fixtureStateKey(rt)) + if !ok { + _ = newFixtureTapFactory(t, context.Background(), rt) + stored, ok = fixtureTapStates.Load(fixtureStateKey(rt)) + if !ok { + t.Fatalf("fixture state not initialized") + } + } + state := stored.(*fixtureTapState) + state.mu.Lock() + defer state.mu.Unlock() + opened := state.kegs["@local/"+alias] + if opened == nil { + t.Fatalf("fixture keg @local/%s not found", alias) + } + return opened +} + +func fixtureContent(t *testing.T, rt *toolkit.Runtime, alias, id string) string { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + raw, err := fixtureKeg(t, rt, alias).GetContent(context.Background(), *nodeID) + if err != nil { + t.Fatalf("read fixture content @local/%s/%s: %v", alias, id, err) + } + return string(raw) +} + +func fixtureSetContent(t *testing.T, rt *toolkit.Runtime, alias, id, content string) { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + if err := fixtureKeg(t, rt, alias).SetContent(context.Background(), *nodeID, []byte(content)); err != nil { + t.Fatalf("write fixture content @local/%s/%s: %v", alias, id, err) + } +} + +func fixtureMeta(t *testing.T, rt *toolkit.Runtime, alias, id string) string { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + raw, err := fixtureKeg(t, rt, alias).GetMetaRaw(context.Background(), *nodeID) + if err != nil { + t.Fatalf("read fixture metadata @local/%s/%s: %v", alias, id, err) + } + return string(raw) +} + +func fixtureSettings(t *testing.T, rt *toolkit.Runtime, alias string) string { + t.Helper() + settings, err := fixtureKeg(t, rt, alias).Settings(context.Background()) + if err != nil { + t.Fatalf("read fixture settings @local/%s: %v", alias, err) + } + return string(settings.Raw()) +} + +func fixtureStats(t *testing.T, rt *toolkit.Runtime, alias, id string) *keg.NodeStats { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + stats, err := fixtureKeg(t, rt, alias).GetStats(context.Background(), *nodeID) + if err != nil { + t.Fatalf("read fixture stats @local/%s/%s: %v", alias, id, err) + } + return stats +} + +func fixtureStatsJSON(t *testing.T, rt *toolkit.Runtime, alias, id string) string { + t.Helper() + raw, err := fixtureStats(t, rt, alias, id).ToJSON() + if err != nil { + t.Fatalf("encode fixture stats @local/%s/%s: %v", alias, id, err) + } + return string(raw) +} + +func fixtureNodeExists(t *testing.T, rt *toolkit.Runtime, alias, id string) bool { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + exists, err := fixtureKeg(t, rt, alias).NodeExists(context.Background(), *nodeID) + if err != nil { + t.Fatalf("check fixture node @local/%s/%s: %v", alias, id, err) + } + return exists +} + +func fixtureFile(t *testing.T, rt *toolkit.Runtime, alias, id, name string, image bool) []byte { + t.Helper() + nodeID, err := keg.ParseNode(id) + if err != nil || nodeID == nil { + t.Fatalf("parse fixture node %q: %v", id, err) + } + var raw []byte + if image { + raw, err = fixtureKeg(t, rt, alias).ReadImage(context.Background(), *nodeID, name) + } else { + raw, err = fixtureKeg(t, rt, alias).ReadFile(context.Background(), *nodeID, name) + } + if err != nil { + t.Fatalf("read fixture attachment @local/%s/%s/%s: %v", alias, id, name, err) + } + return raw +} + +func newFixtureTapFactory(t *testing.T, ctx context.Context, rt *toolkit.Runtime) func(tapper.TapOptions) (*tapper.Tap, error) { + t.Helper() + stateKey := fixtureStateKey(rt) + candidate := &fixtureTapState{kegs: loadFixtureKegs(t, ctx, rt)} + stored, loaded := fixtureTapStates.LoadOrStore(stateKey, candidate) + state := stored.(*fixtureTapState) + if !loaded { + t.Cleanup(func() { fixtureTapStates.Delete(stateKey) }) + } + return func(opts tapper.TapOptions) (*tapper.Tap, error) { + tap, err := tapper.NewTap(opts) + if err != nil { + return nil, err + } + tap.KegResolver = func(_ context.Context, target tapper.KegTargetOptions, _ tapper.FlightRole) (keg.Keg, error) { + alias, namespace := strings.TrimSpace(target.Keg), strings.TrimPrefix(strings.TrimSpace(target.Namespace), "@") + if strings.HasPrefix(alias, "@") { + head, tail, ok := strings.Cut(strings.TrimPrefix(alias, "@"), "/") + if ok { + if namespace != "" && namespace != head { + return nil, fmt.Errorf("keg reference namespace %q conflicts with the namespace %q", head, namespace) + } + namespace, alias = head, tail + } + } + if alias == "" { + if cfg, cfgErr := tap.ConfigService.Config(); cfgErr == nil && cfg != nil { + alias = strings.TrimSpace(cfg.DefaultKeg()) + if alias == "" { + alias = strings.TrimSpace(cfg.LookupAlias(rt, tap.Root)) + } + if namespace == "" { + namespace = strings.TrimPrefix(strings.TrimSpace(cfg.DefaultNamespace()), "@") + if namespace == "" { + namespace = strings.TrimPrefix(strings.TrimSpace(cfg.FallbackNamespace()), "@") + } + } + } + } + if namespace == "" { + namespace = "local" + } + if alias == "" { + return nil, tapper.ErrNotBootstrapped + } + state.mu.Lock() + defer state.mu.Unlock() + if opened := state.kegs["@"+namespace+"/"+alias]; opened != nil { + return opened, nil + } + return nil, fmt.Errorf("keg not initialized: @%s/%s: %w", namespace, alias, keg.ErrNotExist) + } + return tap, nil + } +} + +func loadFixtureKegs(t *testing.T, ctx context.Context, rt *toolkit.Runtime) map[string]keg.Keg { + t.Helper() + home, err := rt.GetHome() + if err != nil { + t.Fatalf("fixture home: %v", err) + } + settingsPaths, err := rt.Glob(filepath.Join(home, "kegs", "@*", "*", "keg")) + if err != nil { + t.Fatalf("find fixture kegs: %v", err) + } + sort.Strings(settingsPaths) + out := make(map[string]keg.Keg, len(settingsPaths)) + for _, settingsPath := range settingsPaths { + base := filepath.Dir(settingsPath) + alias := filepath.Base(base) + namespace := strings.TrimPrefix(filepath.Base(filepath.Dir(base)), "@") + repo := testkegrepo.NewMemoryRepository(rt) + rawSettings, readErr := rt.ReadFile(settingsPath) + if readErr != nil { + t.Fatalf("read %s: %v", settingsPath, readErr) + } + if writeErr := repo.WriteSettingsDocument(ctx, rawSettings); writeErr != nil { + t.Fatalf("load %s settings: %v", settingsPath, writeErr) + } + entries, readErr := rt.ReadDir(base) + if readErr != nil { + t.Fatalf("read %s: %v", base, readErr) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + id, parseErr := keg.ParseNode(entry.Name()) + if parseErr != nil || id == nil { + continue + } + nodeDir := filepath.Join(base, entry.Name()) + content, contentErr := rt.ReadFile(filepath.Join(nodeDir, keg.MarkdownContentFilename)) + if contentErr != nil { + continue + } + if writeErr := repo.WriteContent(ctx, *id, content); writeErr != nil { + t.Fatalf("load %s content: %v", nodeDir, writeErr) + } + if rawMeta, metaErr := rt.ReadFile(filepath.Join(nodeDir, "meta.yaml")); metaErr == nil { + if writeErr := repo.WriteMeta(ctx, *id, rawMeta); writeErr != nil { + t.Fatalf("load %s metadata: %v", nodeDir, writeErr) + } + } + stats := keg.NewStats(rt.Clock().Now()) + if rawStats, statsErr := rt.ReadFile(filepath.Join(nodeDir, "stats.json")); statsErr == nil { + stats, statsErr = keg.ParseStats(ctx, rawStats) + if statsErr != nil { + t.Fatalf("parse %s stats: %v", nodeDir, statsErr) + } + } + if writeErr := repo.WriteStats(ctx, *id, stats); writeErr != nil { + t.Fatalf("load %s stats: %v", nodeDir, writeErr) + } + } + local := keg.NewLocalKeg(repo, rt) + target := keg.NewApi("fixture", namespace, alias, keg.WithHubURL("https://fixture.invalid")) + local.SetTarget(&target) + if dexEntries, dexErr := rt.ReadDir(filepath.Join(base, "dex")); dexErr == nil { + for _, dexEntry := range dexEntries { + if dexEntry.IsDir() { + continue + } + rawIndex, readErr := rt.ReadFile(filepath.Join(base, "dex", dexEntry.Name())) + if readErr != nil { + t.Fatalf("read fixture index %s: %v", dexEntry.Name(), readErr) + } + if writeErr := repo.WriteIndex(ctx, dexEntry.Name(), rawIndex); writeErr != nil { + t.Fatalf("load fixture index %s: %v", dexEntry.Name(), writeErr) + } + } + } + out["@"+namespace+"/"+alias] = local + } + return out +} + // parseCompletionSuggestions parses the raw output of a cobra __complete // invocation and returns the suggestion strings, stopping at the directive line. func parseCompletionSuggestions(raw string) []string { diff --git a/pkg/integrations/adapters/claude_test.go b/pkg/integrations/adapters/claude_test.go index 7d0234d4..7e5fe12a 100644 --- a/pkg/integrations/adapters/claude_test.go +++ b/pkg/integrations/adapters/claude_test.go @@ -69,7 +69,7 @@ func TestClaudeAdapter_RendersGoBackedPreToolUseGuard(t *testing.T) { t.Fatal(err) } pre := hooks.Hooks["PreToolUse"] - if len(pre) != 1 || pre[0].Matcher != "Bash" || len(pre[0].Hooks) != 1 { + if len(pre) != 1 || pre[0].Matcher != "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$" || len(pre[0].Hooks) != 1 { t.Fatalf("Claude PreToolUse hook = %+v", pre) } hook := pre[0].Hooks[0] @@ -88,6 +88,16 @@ func TestClaudeAdapter_BaselineExcludesDeveloperLifecycle(t *testing.T) { } baseline := string(mem.Files()["claude/tapper/skills/tapper/SKILL.md"]) dev := string(mem.Files()["claude/tapper-dev/skills/tapper-dev/SKILL.md"]) + for _, want := range []string{ + "`[title](../NODEID)`", + "`[title](keg:ALIAS/NODEID)`", + "`[title](keg:@NAMESPACE/ALIAS/NODEID)`", + "A bare `keg:` reference in node prose is plain text", + } { + if !strings.Contains(baseline, want) { + t.Errorf("baseline link guidance missing %q", want) + } + } for _, heading := range []string{"## Plan", "## Code", "## Review", "## Commit"} { if strings.Contains(baseline, heading) { t.Errorf("baseline leaked %s", heading) diff --git a/pkg/integrations/adapters/codex.go b/pkg/integrations/adapters/codex.go index 1ade0cf2..aad40e2a 100644 --- a/pkg/integrations/adapters/codex.go +++ b/pkg/integrations/adapters/codex.go @@ -205,9 +205,11 @@ func renderCodexMarketplace() ([]byte, error) { // // TAP_AGENT carries `tap launch --agent` selection. Without it the harness has // the agent but the MCP server it spawns does not, so the session silently -// resolves the configured flight instead of the agent's. TAP_FLIGHT stays -// listed because a human may still export it directly to override; the launcher -// itself no longer sets it. +// resolves the configured flight instead of the agent's. TAP_FLIGHT must be +// forwarded too: the launcher sets it to pin the connection root when a flight +// is configured, and a human may also export it directly. It is deliberately +// unset for a no-flight launch, which is how the spawned `tap mcp` knows to +// resolve identity authority instead of treating itself as launcher-bound. func renderCodexMCP() []byte { return []byte(`{ "mcpServers": { diff --git a/pkg/integrations/adapters/codex_test.go b/pkg/integrations/adapters/codex_test.go index dce5cf80..a7624165 100644 --- a/pkg/integrations/adapters/codex_test.go +++ b/pkg/integrations/adapters/codex_test.go @@ -117,9 +117,7 @@ func TestCodexAdapter_RendersNativeMarketplaceAndTwoPlugins(t *testing.T) { // HOME must be forwarded alongside the XDG roots: tap falls back to it when a // root is unset and when expanding "~", so without it tap mcp fails to // authenticate under Codex while the same tap works in the shell. TAP_AGENT - // carries `tap launch --agent` selection through to the server, which - // resolves the agent's flight itself; TAP_FLIGHT remains forwarded for a - // human overriding it directly. + // carries model/telemetry identity and TAP_FLIGHT carries the pinned root. wantEnvVars := "HOME,TAP_AGENT,TAP_FLIGHT,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME,XDG_CACHE_HOME" if got := strings.Join(tapperMCP.EnvVars, ","); got != wantEnvVars { t.Errorf("tapper MCP env_vars = %q, want %q", got, wantEnvVars) @@ -145,7 +143,7 @@ func TestCodexAdapter_RendersPreToolUseGuardrailWithoutClaudeExpansion(t *testin t.Fatal(err) } pre := hooks.Hooks["PreToolUse"] - if len(pre) != 1 || pre[0].Matcher != "Bash" || len(pre[0].Hooks) != 1 { + if len(pre) != 1 || pre[0].Matcher != "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$" || len(pre[0].Hooks) != 1 { t.Fatalf("Codex PreToolUse hook = %+v", pre) } hook := pre[0].Hooks[0] @@ -212,6 +210,16 @@ func TestCodexAdapter_SeparatesBaselineAndDeveloperWorkflow(t *testing.T) { if !strings.Contains(baseline, "mcp__tapper__orient") || !strings.Contains(baseline, "Secret handling") { t.Fatalf("baseline lacks orientation or safety: %s", baseline) } + for _, want := range []string{ + "`[title](../NODEID)`", + "`[title](keg:ALIAS/NODEID)`", + "`[title](keg:@NAMESPACE/ALIAS/NODEID)`", + "A bare `keg:` reference in node prose is plain text", + } { + if !strings.Contains(baseline, want) { + t.Errorf("baseline link guidance missing %q", want) + } + } for _, lifecycle := range []string{"## Plan", "## Code", "## Review", "## Commit"} { if strings.Contains(baseline, lifecycle) { t.Errorf("baseline leaked %s", lifecycle) diff --git a/pkg/integrations/renderdata/claude/hooks/hooks.json b/pkg/integrations/renderdata/claude/hooks/hooks.json index a83fde25..2232f534 100644 --- a/pkg/integrations/renderdata/claude/hooks/hooks.json +++ b/pkg/integrations/renderdata/claude/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$", "hooks": [ { "type": "command", diff --git a/pkg/integrations/renderdata/codex/hooks/hooks.json b/pkg/integrations/renderdata/codex/hooks/hooks.json index bfc732d9..58e02735 100644 --- a/pkg/integrations/renderdata/codex/hooks/hooks.json +++ b/pkg/integrations/renderdata/codex/hooks/hooks.json @@ -15,7 +15,7 @@ ], "PreToolUse": [ { - "matcher": "Bash", + "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit|Shell|exec_command|apply_patch|write_file|edit_file|delete_file|move_file|rename_file)$", "hooks": [ { "type": "command", diff --git a/pkg/integrations/renderdata/renderdata.go b/pkg/integrations/renderdata/renderdata.go index 638f152e..dd9dea19 100644 --- a/pkg/integrations/renderdata/renderdata.go +++ b/pkg/integrations/renderdata/renderdata.go @@ -2,7 +2,7 @@ // render-integrations command overlays onto the canonical content tree. // // This package exists to keep these host-specific source bytes (today: the -// host plugin hooks) out of the cmd/tap and cmd/keg +// host plugin hooks) out of cmd/tap // binaries. Only cmd/render-integrations imports this package; the rendered // output of those bytes ships in the user binaries via integrations/embed.go, // not via this embed FS. diff --git a/pkg/keg/archive.go b/pkg/keg/archive.go index f02421f6..a9481bee 100644 --- a/pkg/keg/archive.go +++ b/pkg/keg/archive.go @@ -16,21 +16,21 @@ import ( "time" ) -// Archive format identifiers. v3 adds optional keg config, optional keg schemas, +// Archive format identifiers. v3 adds optional keg settings, optional keg schemas, // and stores file attachments under assets/ to match the on-disk/web node layout. const ( kegArchiveFormatV3 = "keg-archive/v3" ) type archiveManifest struct { - Format string `json:"format"` - Source string `json:"source,omitempty"` - ExportedAt time.Time `json:"exported_at"` - WithHistory bool `json:"with_history,omitempty"` - WithConfig bool `json:"with_config,omitempty"` - WithSchemas bool `json:"with_schemas,omitempty"` - Schemas []string `json:"schemas,omitempty"` - Nodes []archiveManifestNode `json:"nodes"` + Format string `json:"format"` + Source string `json:"source,omitempty"` + ExportedAt time.Time `json:"exported_at"` + WithHistory bool `json:"with_history,omitempty"` + WithSettings bool `json:"with_settings,omitempty"` + WithSchemas bool `json:"with_schemas,omitempty"` + Schemas []string `json:"schemas,omitempty"` + Nodes []archiveManifestNode `json:"nodes"` } type archiveManifestNode struct { @@ -115,26 +115,26 @@ func (k *LocalKeg) exportNodes(ctx context.Context, opts ExportNodesOptions) (io func (k *LocalKeg) writeArchive(ctx context.Context, w io.Writer, ids []NodeId, snapshotRepo RepositorySnapshots, opts ExportNodesOptions) error { gz := gzip.NewWriter(w) tw := tar.NewWriter(gz) - withConfig := len(opts.NodeIDs) == 0 && strings.TrimSpace(opts.Query) == "" && !opts.SkipZeroNode + withSettings := len(opts.NodeIDs) == 0 && strings.TrimSpace(opts.Query) == "" && !opts.SkipZeroNode manifest := archiveManifest{ - Format: kegArchiveFormatV3, - Source: opts.Source, - ExportedAt: k.Runtime.Clock().Now().UTC(), - WithHistory: opts.WithHistory, - WithConfig: withConfig, + Format: kegArchiveFormatV3, + Source: opts.Source, + ExportedAt: k.Runtime.Clock().Now().UTC(), + WithHistory: opts.WithHistory, + WithSettings: withSettings, } - if withConfig { - cfg, err := k.Repo.ReadConfig(ctx) + if withSettings { + cfg, err := k.Repo.ReadSettings(ctx) if err != nil { - return fmt.Errorf("unable to read keg config for archive: %w", err) + return fmt.Errorf("unable to read keg settings for archive: %w", err) } - rawConfig, err := cfg.ToYAML() + rawSettings, err := cfg.ToYAML() if err != nil { - return fmt.Errorf("unable to encode keg config for archive: %w", err) + return fmt.Errorf("unable to encode keg settings for archive: %w", err) } - if err := writeTarFile(tw, "keg-archive/keg.yaml", rawConfig); err != nil { + if err := writeTarFile(tw, "keg-archive/keg.yaml", rawSettings); err != nil { return err } if err := k.writeArchiveSchemas(ctx, tw, &manifest); err != nil { @@ -323,7 +323,7 @@ func (k *LocalKeg) writeArchiveHistory(ctx context.Context, tw *tar.Writer, base // ImportNodes loads a keg-archive stream into the keg. Nodes land on their // archive ids, replacing existing nodes (whose assets are preserved unless the -// archive carries its own). Derived state (dex, config updated stamp) is +// archive carries its own). Derived state (dex, settings updated stamp) is // rebuilt once after all nodes import. func (k *LocalKeg) ImportNodes(ctx context.Context, r io.Reader, opts ImportNodesOptions) ([]ImportedNode, error) { return withKegAtomicWriteValue(ctx, k, func(ctx context.Context) ([]ImportedNode, error) { @@ -398,13 +398,13 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode manifestNodes[node.SourceID] = node } - if manifest.WithConfig { - rawConfig, err := readRequiredArchiveEntry(entries, "keg-archive/keg.yaml") + if manifest.WithSettings { + rawSettings, err := readRequiredArchiveEntry(entries, "keg-archive/keg.yaml") if err != nil { - return nil, fmt.Errorf("archive missing keg config: %w", err) + return nil, fmt.Errorf("archive missing keg settings: %w", err) } - if _, err := ParseKegConfigStrict(rawConfig); err != nil { - return nil, fmt.Errorf("archive keg config is invalid: %w", err) + if _, err := ParseKegSettingsStrict(rawSettings); err != nil { + return nil, fmt.Errorf("archive keg settings is invalid: %w", err) } } if err := validateArchiveAssetEntries(entries); err != nil { @@ -536,21 +536,21 @@ func (k *LocalKeg) importNodes(ctx context.Context, r io.Reader, opts ImportNode return nil, err } } - if manifest.WithConfig { - rawConfig, err := readRequiredArchiveEntry(entries, "keg-archive/keg.yaml") + if manifest.WithSettings { + rawSettings, err := readRequiredArchiveEntry(entries, "keg-archive/keg.yaml") if err != nil { - return nil, fmt.Errorf("archive missing keg config: %w", err) + return nil, fmt.Errorf("archive missing keg settings: %w", err) } - if err := k.SetConfig(ctx, rawConfig); err != nil { - return nil, fmt.Errorf("unable to restore keg config after import: %w", err) + if err := k.replaceSettings(ctx, rawSettings); err != nil { + 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.WithConfig { - if err := k.touchConfigUpdated(ctx, k.Runtime.Clock().Now()); err != nil { - return nil, fmt.Errorf("unable to update keg config after import: %w", 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 5565ad30..9cf0be68 100644 --- a/pkg/keg/archive_test.go +++ b/pkg/keg/archive_test.go @@ -103,7 +103,7 @@ func (r *failSecondNextRepo) Next(ctx context.Context) (keg.NodeId, error) { func TestArchiveManifestRecordsSourceHash(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) created, err := src.Create(ctx, &keg.CreateOptions{Body: []byte("# Source\n\nbody\n")}) require.NoError(t, err) @@ -125,14 +125,14 @@ func TestArchiveManifestRecordsSourceHash(t *testing.T) { func TestImportHistoryIfSupportedFallsBackWithoutSnapshots(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) created, err := src.Create(ctx, &keg.CreateOptions{Body: []byte("# Source\n\nbody\n")}) require.NoError(t, err) require.NoError(t, src.Commit(ctx, created.ID)) archive := mustExportArchive(t, src, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{created.ID}, WithHistory: true}) - dst := keg.NewLocalKeg(&repoWithoutSchemas{Repository: keg.NewMemoryRepo(fx.Runtime())}, fx.Runtime()) + dst := keg.NewLocalKeg(&repoWithoutSchemas{Repository: newTestMemoryRepo(fx.Runtime())}, fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{HistoryIfSupported: true}) require.NoError(t, err) @@ -144,7 +144,7 @@ func TestImportHistoryIfSupportedFallsBackWithoutSnapshots(t *testing.T) { func TestImportCleansUnusedIDReservationsAfterAllocationFailure(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) one, err := src.Create(ctx, &keg.CreateOptions{Body: []byte("# One\n")}) require.NoError(t, err) @@ -152,7 +152,7 @@ func TestImportCleansUnusedIDReservationsAfterAllocationFailure(t *testing.T) { require.NoError(t, err) archive := mustExportArchive(t, src, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{one.ID, two.ID}}) - base := keg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) dst := keg.NewLocalKeg(base, fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) failing := keg.NewLocalKeg(&failSecondNextRepo{Repository: base}, fx.Runtime()) @@ -184,7 +184,7 @@ func TestArchiveExportUsesAssetsDirectoryAndIncludesConfigForFullBackup(t *testi fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) id, err := src.Create(ctx, &keg.CreateOptions{Title: "asset node", Body: []byte("# asset node\n")}) require.NoError(t, err) @@ -194,12 +194,12 @@ func TestArchiveExportUsesAssetsDirectoryAndIncludesConfigForFullBackup(t *testi entries := readArchiveEntriesForTest(t, mustExportArchive(t, src, keg.ExportNodesOptions{WithAssets: true})) var manifest struct { - Format string `json:"format"` - WithConfig bool `json:"with_config"` + Format string `json:"format"` + WithSettings bool `json:"with_settings"` } require.NoError(t, json.Unmarshal(entries["keg-archive/manifest.json"], &manifest)) require.Equal(t, "keg-archive/v3", manifest.Format) - require.True(t, manifest.WithConfig) + require.True(t, manifest.WithSettings) require.Contains(t, entries, "keg-archive/keg.yaml") require.Contains(t, entries, "keg-archive/nodes/"+id.ID.Path()+"/assets/doc.txt") require.Contains(t, entries, "keg-archive/nodes/"+id.ID.Path()+"/images/diagram.png") @@ -213,9 +213,9 @@ func TestArchiveExportFullBackupIncludesSchemas(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) entries := readArchiveEntriesForTest(t, mustExportArchive(t, src, keg.ExportNodesOptions{})) @@ -229,16 +229,16 @@ func TestArchiveExportFullBackupIncludesSchemas(t *testing.T) { require.Equal(t, archiveTaskSchema, entries["keg-archive/schemas/task.schema.yaml"]) } -func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { +func TestArchiveImportRestoresKegSettingsForFullBackup(t *testing.T) { t.Parallel() fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) _, err := src.Create(ctx, &keg.CreateOptions{Title: "indexed", Body: []byte("# indexed\n"), Tags: []string{"restored"}}) require.NoError(t, err) - require.NoError(t, src.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, src.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.Title = "Restored Title" cfg.URL = "https://example.com/restored" cfg.Creator = "restorer" @@ -248,7 +248,7 @@ func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { cfg.Timezone = "America/Chicago" cfg.Links = []keg.LinkEntry{{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.SnapshotConfig{Mode: keg.SnapshotModeOff, IdleAfter: "2h"} + cfg.Snapshots = &keg.SnapshotSettings{Mode: keg.SnapshotModeOff, IdleAfter: "2h"} cfg.SchemaPolicy = &keg.SchemaPolicy{ Human: keg.ValidationModeWarn, Agent: keg.ValidationModeBlock, @@ -258,9 +258,9 @@ func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { archive := mustExportArchive(t, src, keg.ExportNodesOptions{WithAssets: true}) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) - require.NoError(t, dst.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, dst.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.Title = "Target Title" cfg.Summary = "Target summary" cfg.Timezone = "UTC" @@ -269,7 +269,7 @@ func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) require.NoError(t, err) - got, err := dst.Config(ctx) + got, err := dst.Settings(ctx) require.NoError(t, err) require.Equal(t, "Restored Title", got.Title) require.Equal(t, "https://example.com/restored", got.URL) @@ -279,7 +279,7 @@ func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { 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.SnapshotConfig{Mode: keg.SnapshotModeOff, IdleAfter: "2h"}, got.Snapshots) + 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) rawIndex, err := dst.ReadIndex(ctx, "restored.md") @@ -287,16 +287,16 @@ func TestArchiveImportRestoresKegConfigForFullBackup(t *testing.T) { require.Contains(t, string(rawIndex), "indexed") } -func TestArchiveImportNodeSubsetDoesNotRestoreKegConfig(t *testing.T) { +func TestArchiveImportNodeSubsetDoesNotRestoreKegSettings(t *testing.T) { t.Parallel() fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) id, err := src.Create(ctx, &keg.CreateOptions{Title: "partial", Body: []byte("# partial\n")}) require.NoError(t, err) - require.NoError(t, src.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, src.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.Title = "Source Title" cfg.Summary = "Source summary" })) @@ -305,21 +305,21 @@ func TestArchiveImportNodeSubsetDoesNotRestoreKegConfig(t *testing.T) { entries := readArchiveEntriesForTest(t, archive) require.NotContains(t, entries, "keg-archive/keg.yaml") var manifest struct { - WithConfig bool `json:"with_config"` + WithSettings bool `json:"with_settings"` } require.NoError(t, json.Unmarshal(entries["keg-archive/manifest.json"], &manifest)) - require.False(t, manifest.WithConfig) + require.False(t, manifest.WithSettings) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) - require.NoError(t, dst.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, dst.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.Title = "Target Title" cfg.Summary = "Target summary" })) _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) require.NoError(t, err) - got, err := dst.Config(ctx) + got, err := dst.Settings(ctx) require.NoError(t, err) require.Equal(t, "Target Title", got.Title) require.Equal(t, "Target summary", got.Summary) @@ -330,9 +330,9 @@ func TestArchiveExportNodeSubsetOmitsSchemas(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) id, err := src.Create(ctx, &keg.CreateOptions{ Body: []byte("---\ntype: task\n---\n# Partial\n"), }) @@ -375,20 +375,20 @@ markdown: summary: Target decisions `) - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) markZeroAsTask(t, src) - require.NoError(t, src.WriteSchema(ctx, "task", archivedSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archivedSchema)) _, err := src.Create(ctx, &keg.CreateOptions{ Body: []byte("---\ntype: task\n---\n# Imported Task\n"), }) require.NoError(t, err) archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) - require.NoError(t, dst.WriteSchema(ctx, "task", targetSchema)) - require.NoError(t, dst.WriteSchema(ctx, "decision", targetOnlySchema)) + require.NoError(t, dst.CreateSchema(ctx, "task", targetSchema)) + require.NoError(t, dst.CreateSchema(ctx, "decision", targetOnlySchema)) _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) require.NoError(t, err) @@ -415,19 +415,19 @@ markdown: required: true `) - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) markZeroAsTask(t, src) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) id, err := src.Create(ctx, &keg.CreateOptions{ Body: []byte("---\ntype: task\n---\n# Accepted By Archive Schema\n"), }) require.NoError(t, err) archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) - require.NoError(t, dst.WriteSchema(ctx, "task", targetSchema)) + require.NoError(t, dst.CreateSchema(ctx, "task", targetSchema)) _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) require.NoError(t, err) @@ -441,15 +441,15 @@ func TestArchiveImportSkipsSchemaEnforcementEvenWithBlockOverride(t *testing.T) fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) humanCtx := keg.WithValidationActor(ctx, keg.ValidationActorHuman) id, err := src.Create(humanCtx, &keg.CreateOptions{Body: []byte("# Missing Type\n")}) require.NoError(t, err) archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) blockCtx := keg.WithValidationMode(ctx, keg.ValidationModeBlock) _, err = dst.ImportNodes(blockCtx, bytes.NewReader(archive), keg.ImportNodesOptions{}) @@ -464,7 +464,7 @@ func TestArchiveImportDropsLegacySchemaPolicyFields(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) legacyConfig := []byte(`kegv: "2025-07" @@ -479,11 +479,11 @@ schemaPolicy: `) archive = replaceArchiveEntry(t, archive, "keg-archive/keg.yaml", legacyConfig) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) _, err := dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) require.NoError(t, err) - cfg, err := dst.Config(ctx) + cfg, err := dst.Settings(ctx) require.NoError(t, err) require.Equal(t, &keg.SchemaPolicy{ Human: keg.ValidationModeWarn, @@ -502,10 +502,10 @@ func TestArchiveImportRejectsMalformedSchemaBeforeWritingNodes(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) markZeroAsTask(t, src) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) id, err := src.Create(ctx, &keg.CreateOptions{ Body: []byte("---\ntype: task\n---\n# Imported Task\n"), }) @@ -513,7 +513,7 @@ func TestArchiveImportRejectsMalformedSchemaBeforeWritingNodes(t *testing.T) { archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) broken := replaceArchiveEntry(t, archive, "keg-archive/schemas/task.schema.yaml", []byte("type: [")) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) _, err = dst.ImportNodes(ctx, bytes.NewReader(broken), keg.ImportNodesOptions{}) require.Error(t, err) @@ -529,17 +529,17 @@ func TestArchiveImportRejectsSchemasWhenTargetDoesNotSupportThemBeforeWritingNod fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) markZeroAsTask(t, src) - require.NoError(t, src.WriteSchema(ctx, "task", archiveTaskSchema)) + require.NoError(t, src.CreateSchema(ctx, "task", archiveTaskSchema)) id, err := src.Create(ctx, &keg.CreateOptions{ Body: []byte("---\ntype: task\n---\n# Imported Task\n"), }) require.NoError(t, err) archive := mustExportArchive(t, src, keg.ExportNodesOptions{}) - dstRepo := &repoWithoutSchemas{Repository: keg.NewMemoryRepo(fx.Runtime())} + dstRepo := &repoWithoutSchemas{Repository: newTestMemoryRepo(fx.Runtime())} dst := keg.NewLocalKeg(dstRepo, fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) _, err = dst.ImportNodes(ctx, bytes.NewReader(archive), keg.ImportNodesOptions{}) @@ -554,7 +554,7 @@ func TestArchiveImportRejectsNestedAssetNameBeforeWritingNodes(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - src := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + src := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, src, ctx) id, err := src.Create(ctx, &keg.CreateOptions{Title: "asset node", Body: []byte("# asset node\n")}) require.NoError(t, err) @@ -568,7 +568,7 @@ func TestArchiveImportRejectsNestedAssetNameBeforeWritingNodes(t *testing.T) { "keg-archive/nodes/"+id.ID.Path()+"/assets/nested/doc.txt", ) - dst := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + dst := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, dst, ctx) _, err = dst.ImportNodes(ctx, bytes.NewReader(broken), keg.ImportNodesOptions{}) require.ErrorIs(t, err, keg.ErrInvalidAssetName) diff --git a/pkg/keg/asset_name.go b/pkg/keg/asset_name.go index cbb81935..402153b3 100644 --- a/pkg/keg/asset_name.go +++ b/pkg/keg/asset_name.go @@ -12,8 +12,8 @@ import ( // strips any directory prefix, so a legitimate name never contains a separator. // // Anything containing a path separator, a "." / ".." component, or an absolute -// path is rejected so that filepath.Join in the filesystem backend cannot -// resolve outside the keg root (CWE-22). Enforced at every Repository asset +// path is rejected so repository implementations cannot resolve outside the +// node's attachment namespace (CWE-22). Enforced at every Repository asset // sink, which is the only chokepoint that also covers archive import — that // path writes assets through the Repository directly, bypassing LocalKeg. func validAssetName(name string) error { diff --git a/pkg/keg/asset_name_test.go b/pkg/keg/asset_name_test.go index 33ee6f61..e939afa7 100644 --- a/pkg/keg/asset_name_test.go +++ b/pkg/keg/asset_name_test.go @@ -27,51 +27,39 @@ var badAssetNames = []string{ } // TestAssetName_RejectsTraversal pins that the repository asset boundary rejects -// traversing/separator names and never touches anything outside the keg root, -// while ordinary single-component names still round-trip. +// traversing/separator names while ordinary single-component names round-trip. func TestAssetName_RejectsTraversal(t *testing.T) { t.Parallel() fx := NewSandbox(t) ctx := fx.Context() rt := fx.Runtime() - base := t.TempDir() - kegRoot := filepath.Join(base, "kegroot") - r := keg.NewFsRepo(kegRoot, rt) + r := newTestMemoryRepo(rt) id := keg.NodeId{ID: 0} require.NoError(t, r.WriteContent(ctx, id, []byte("# zero\n"))) for _, name := range badAssetNames { - require.ErrorIsf(t, r.WriteAsset(ctx, id, keg.AssetKindImage, name, []byte("x")), + require.ErrorIsf(t, r.WriteImage(ctx, id, name, []byte("x")), keg.ErrInvalidAssetName, "WriteAsset(%q)", name) _, err := r.ReadImage(ctx, id, name) require.ErrorIsf(t, err, keg.ErrInvalidAssetName, "ReadImage(%q)", name) _, err = r.ReadFile(ctx, id, name) require.ErrorIsf(t, err, keg.ErrInvalidAssetName, "ReadFile(%q)", name) - require.ErrorIsf(t, r.DeleteAsset(ctx, id, keg.AssetKindImage, name), + require.ErrorIsf(t, r.DeleteImage(ctx, id, name), keg.ErrInvalidAssetName, "DeleteAsset(%q)", name) } - // The traversal target must never have been created. - _, statErr := rt.Stat(filepath.Join(base, "escape.txt"), false) - require.True(t, os.IsNotExist(statErr), "no file may escape the keg root") - - // MemoryRepo enforces the same guard (Fs/Memory parity). - m := keg.NewMemoryRepo(rt) - require.NoError(t, m.WriteContent(ctx, id, []byte("# zero\n"))) - require.ErrorIs(t, m.WriteAsset(ctx, id, keg.AssetKindImage, "../x", []byte("x")), keg.ErrInvalidAssetName) - - // Ordinary names still work on both backends. + // Ordinary names still work. for _, repo := range []interface { - WriteAsset(c context.Context, id keg.NodeId, k keg.AssetKind, name string, data []byte) error + WriteImage(c context.Context, id keg.NodeId, name string, data []byte) error ReadImage(c context.Context, id keg.NodeId, name string) ([]byte, error) - DeleteAsset(c context.Context, id keg.NodeId, k keg.AssetKind, name string) error - }{r, m} { - require.NoError(t, repo.WriteAsset(ctx, id, keg.AssetKindImage, "a.png", []byte("png"))) + DeleteImage(c context.Context, id keg.NodeId, name string) error + }{r} { + require.NoError(t, repo.WriteImage(ctx, id, "a.png", []byte("png"))) got, err := repo.ReadImage(ctx, id, "a.png") require.NoError(t, err) require.Equal(t, []byte("png"), got) - require.NoError(t, repo.DeleteAsset(ctx, id, keg.AssetKindImage, "a.png")) + require.NoError(t, repo.DeleteImage(ctx, id, "a.png")) } } @@ -122,7 +110,7 @@ func TestImport_RejectsZipSlipArchive(t *testing.T) { rt := fx.Runtime() // Build a legitimate archive (valid manifest/meta/stats) with one attachment. - src := keg.NewLocalKeg(keg.NewMemoryRepo(rt), rt) + src := keg.NewLocalKeg(newTestMemoryRepo(rt), rt) initNonStrictTestKeg(t, src, ctx) nid, err := src.Create(ctx, &keg.CreateOptions{Title: "x", Body: []byte("# x\n")}) require.NoError(t, err) @@ -136,7 +124,7 @@ func TestImport_RejectsZipSlipArchive(t *testing.T) { base := t.TempDir() // Clean import still works (no regression). - okKeg := keg.NewLocalKeg(keg.NewFsRepo(filepath.Join(base, "ok"), rt), rt) + okKeg := keg.NewLocalKeg(newTestMemoryRepo(rt), rt) initNonStrictTestKeg(t, okKeg, ctx) _, err = okKeg.ImportNodes(ctx, bytes.NewReader(clean), keg.ImportNodesOptions{}) require.NoError(t, err, "a clean archive must still import") @@ -146,8 +134,7 @@ func TestImport_RejectsZipSlipArchive(t *testing.T) { to := "keg-archive/nodes/" + nid.ID.Path() + "/assets/../../../PWNED.txt" evil := retarWithRenamedEntry(t, clean, from, to) - evilRoot := filepath.Join(base, "evil") - evilKeg := keg.NewLocalKeg(keg.NewFsRepo(evilRoot, rt), rt) + evilKeg := keg.NewLocalKeg(newTestMemoryRepo(rt), rt) initNonStrictTestKeg(t, evilKeg, ctx) _, err = evilKeg.ImportNodes(ctx, bytes.NewReader(evil), keg.ImportNodesOptions{}) require.Error(t, err, "a hostile archive must be rejected") diff --git a/pkg/keg/constants.go b/pkg/keg/constants.go index 68944067..b0a64e6d 100644 --- a/pkg/keg/constants.go +++ b/pkg/keg/constants.go @@ -12,15 +12,19 @@ you would like this content created. ` var ( - // ConfigV1VersionString is the initial KEG configuration version identifier. - ConfigV1VersionString = "2023-01" + // SettingsV1VersionString is the initial KEG settings version identifier. + SettingsV1VersionString = "2023-01" - // ConfigV2VersionString is the current KEG configuration version identifier. - ConfigV2VersionString = "2025-07" + // SettingsV2VersionString is the current KEG settings version identifier. + SettingsV2VersionString = "2025-07" // FormatMarkdown is the short format identifier for Markdown content. FormatMarkdown = "markdown" + // MarkdownContentFilename is the canonical filename hint used by KEG + // archives and content parsers. It does not identify a storage backend. + MarkdownContentFilename = "README.md" + // FormatRST is the short format identifier for reStructuredText content. FormatRST = "rst" ) diff --git a/pkg/keg/content.go b/pkg/keg/content.go index 0c7484d5..e7da8dc1 100644 --- a/pkg/keg/content.go +++ b/pkg/keg/content.go @@ -231,6 +231,22 @@ func extractMarkdownTitleAndLead(data []byte) (string, string) { return title, "" } +// ExplicitMarkdownTitle returns the first explicit Markdown H1 after optional +// YAML frontmatter. It deliberately does not use ParseContent's fallback to a +// first non-empty line: create APIs use this helper when their contract +// requires the caller to supply an actual "# Title" heading. +func ExplicitMarkdownTitle(data []byte) string { + _, body := extractMarkdownFrontmatter(data) + scanner := bufio.NewScanner(bytes.NewReader(body)) + for scanner.Scan() { + trimmed := strings.TrimSpace(scanner.Text()) + if title, ok := strings.CutPrefix(trimmed, "# "); ok { + return strings.TrimSpace(title) + } + } + return "" +} + // extractRSTTitleAndLead detects an RST-style title: first line text and the // second line consisting entirely of '=' or '-' (a common RST underline). // The lead is the first paragraph after the underline block. If the RST-style diff --git a/pkg/keg/content_test.go b/pkg/keg/content_test.go index e4e12f79..726db17f 100644 --- a/pkg/keg/content_test.go +++ b/pkg/keg/content_test.go @@ -61,6 +61,25 @@ Another paragraph. require.Equal(t, "This is the first paragraph after the title fallback.", c.Lead) } +func TestExplicitMarkdownTitle(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + body string + want string + }{ + {name: "heading", body: "# Explicit\n", want: "Explicit"}, + {name: "frontmatter", body: "---\ntype: note\n---\n# After metadata\n", want: "After metadata"}, + {name: "first explicit heading wins", body: "fallback\n# First\n# Second\n", want: "First"}, + {name: "fallback is not explicit", body: "Fallback title\n", want: ""}, + {name: "empty heading", body: "# \n", want: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, keg.ExplicitMarkdownTitle([]byte(tc.body))) + }) + } +} + func TestParseContent_EmptyInputReturnsEmptyFormat(t *testing.T) { t.Parallel() rt := testRuntime(t) @@ -162,3 +181,17 @@ Also reference bare ../99 in text and ../42 again. expected := []keg.NodeId{{ID: 42}, {ID: 99}} require.Equal(t, expected, c.Links) } + +func TestParseContent_BareKegReferenceDoesNotCreateLocalLink(t *testing.T) { + t.Parallel() + rt := testRuntime(t) + + c, err := keg.ParseContent(rt, []byte(`# Link forms + +[Local](../42) is indexed locally. +[Configured cross-keg](keg:public/7) is a Markdown link. +Bare keg:public/99 is prose, not a graph link. +`), "README.md") + require.NoError(t, err) + require.Equal(t, []keg.NodeId{{ID: 42}}, c.Links) +} diff --git a/pkg/keg/dex.go b/pkg/keg/dex.go index cb94c933..5e96ae85 100644 --- a/pkg/keg/dex.go +++ b/pkg/keg/dex.go @@ -31,7 +31,7 @@ type Dex struct { // changes is the reverse-chronological list of all nodes. changes ChangesIndex - // custom holds config-driven query-filtered index builders. + // custom holds settings-driven query-filtered index builders. custom []IndexBuilder // queryResolver is an optional callback injected via WithQueryResolver. @@ -45,7 +45,21 @@ type Dex struct { // DexOption is a functional option for NewDexFromRepo. type DexOption func(*Dex) error -// WithConfig builds DexOptions from a keg Config. It iterates cfg.Indexes and +type indexReader interface { + GetIndex(context.Context, string) ([]byte, error) +} + +type indexMapReader map[string]string + +func (r indexMapReader) GetIndex(_ context.Context, name string) ([]byte, error) { + data, ok := r[name] + if !ok { + return nil, ErrNotExist + } + return []byte(data), nil +} + +// WithSettings builds DexOptions from a keg Settings. It iterates cfg.Indexes and // creates a QueryFilteredIndex for each entry that: // - has a non-empty Query field, and // - is not one of the core protected index names. @@ -56,7 +70,7 @@ type DexOption func(*Dex) error // By default, the index evaluates tag expressions against node tag sets. To // support richer query terms (e.g. key=value attribute predicates), pass // WithQueryResolver to inject a custom resolver callback. -func WithConfig(cfg *Config) DexOption { +func WithSettings(cfg *Settings) DexOption { return func(d *Dex) error { if cfg == nil { return nil @@ -76,7 +90,7 @@ func WithConfig(cfg *Config) DexOption { } idx, err := NewQueryFilteredIndexWithSort(entry.File, query, resolver, sortOrder) if err != nil { - return fmt.Errorf("dex: config index %q: %w", entry.File, err) + return fmt.Errorf("dex: settings index %q: %w", entry.File, err) } d.custom = append(d.custom, idx) } @@ -84,7 +98,7 @@ func WithConfig(cfg *Config) DexOption { } } -// WithQueryResolver sets a custom query term resolver for config-driven custom +// WithQueryResolver sets a custom query term resolver for settings-driven custom // indexes. When set, each term in a query expression is resolved by calling // resolve(term, data) for each node, instead of the default tag-only resolver. // This enables key=value attribute predicates and other term types defined in @@ -100,11 +114,15 @@ func WithQueryResolver(resolve func(term string, data *NodeData) bool) DexOption // "backlinks", "changes.md") from the provided repository and returns a Dex // populated with parsed indexes. Missing or empty index files are treated as // empty datasets and do not cause an error. Additional DexOptions (e.g. -// WithConfig) can be supplied to configure optional behaviour such as +// WithSettings) can be supplied to configure optional behaviour such as // tag-filtered custom indexes. // // All 5 index files are read and parsed concurrently for faster loading. func NewDexFromRepo(ctx context.Context, repo Repository, opts ...DexOption) (*Dex, error) { + return newDexFromIndexReader(ctx, repo, opts...) +} + +func newDexFromIndexReader(ctx context.Context, repo indexReader, opts ...DexOption) (*Dex, error) { d := &Dex{} // Each goroutine writes to its own result slot; no shared mutable state. @@ -124,7 +142,7 @@ func NewDexFromRepo(ctx context.Context, repo Repository, opts ...DexOption) (*D var wg sync.WaitGroup run := func(fn func()) { - if repositorySupportsConcurrentAccess(ctx, repo) { + if repository, ok := repo.(Repository); ok && repositorySupportsConcurrentAccess(ctx, repository) { wg.Go(fn) return } @@ -256,7 +274,7 @@ func NewDexFromRepo(ctx context.Context, repo Repository, opts ...DexOption) (*D errs = append(errs, changeErr) } - // Apply options (e.g. WithConfig to register custom tag-filtered indexes). + // Apply options (e.g. WithSettings to register custom tag-filtered indexes). for _, opt := range opts { if err := opt(d); err != nil { errs = append(errs, err) diff --git a/pkg/keg/dex_changes_test.go b/pkg/keg/dex_changes_test.go index ee44e933..3af9e542 100644 --- a/pkg/keg/dex_changes_test.go +++ b/pkg/keg/dex_changes_test.go @@ -122,7 +122,7 @@ func TestChangesIndex_ParseAndRoundTrip(t *testing.T) { ctx := context.Background() raw := "* 2025-10-03 20:52:37Z [Tap CLI application (`tap`)](../31)\n" + - "* 2025-09-18 00:51:16Z [Zekia extension to keg configuration](../24)\n" + "* 2025-09-18 00:51:16Z [Zekia extension to keg settings](../24)\n" idx, err := ParseChangesIndex(ctx, []byte(raw)) require.NoError(t, err) diff --git a/pkg/keg/dex_concurrent_test.go b/pkg/keg/dex_concurrent_test.go index 6248034f..db287556 100644 --- a/pkg/keg/dex_concurrent_test.go +++ b/pkg/keg/dex_concurrent_test.go @@ -66,7 +66,7 @@ func TestDexWrite_ConcurrentSafe(t *testing.T) { require.NoError(t, err) ctx := t.Context() - repo := NewMemoryRepo(rt) + repo := newTestMemoryRepo(rt) dex := &Dex{} now := time.Date(2025, 10, 15, 12, 0, 0, 0, time.UTC) diff --git a/pkg/keg/dex_test.go b/pkg/keg/dex_test.go index 3a503aee..849cad79 100644 --- a/pkg/keg/dex_test.go +++ b/pkg/keg/dex_test.go @@ -28,7 +28,7 @@ func TestReadFromDex_Table(t *testing.T) { name: "basic", nodesTSV: "" + "0\t2025-08-04T22:03:53Z\t2025-08-04T22:03:53Z\t2025-08-04T22:03:53Z\tSorry, planned but not yet available\n" + - "1\t2025-08-04T23:06:30Z\t2025-08-04T23:06:30Z\t2025-08-04T23:06:30Z\tConfiguration (config)\n" + + "1\t2025-08-04T23:06:30Z\t2025-08-04T23:06:30Z\t2025-08-04T23:06:30Z\tConfiguration (settings)\n" + "3\t2025-08-09T17:44:04Z\t2025-08-09T17:44:04Z\t2025-08-09T17:44:04Z\tZeke AI utility (zeke)\n" + "badline-without-tabs\n" + // malformed - should be skipped "999\tnot-a-time\t\t\tTitle with bad time\n", // id parses, time parse will produce zero time @@ -49,7 +49,7 @@ func TestReadFromDex_Table(t *testing.T) { wantNodes: map[int]string{ 0: "Sorry, planned but not yet available", - 1: "Configuration (config)", + 1: "Configuration (settings)", 3: "Zeke AI utility (zeke)", 999: "Title with bad time", }, @@ -114,7 +114,7 @@ func TestReadFromDex_Table(t *testing.T) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) // write indexes only if non-empty (tests may want to omit them) if tc.nodesTSV != "" { @@ -217,7 +217,7 @@ func TestDex_WritesChanges(t *testing.T) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) dex, err := NewDexFromRepo(t.Context(), mem) require.NoError(t, err) @@ -248,23 +248,23 @@ func TestDex_WritesChanges(t *testing.T) { require.Contains(t, s, "[Beta](../2)") } -// TestDex_WithConfig_CustomIndex verifies that WithConfig registers +// TestDex_WithConfig_CustomIndex verifies that WithSettings registers // query-filtered custom indexes that are written on Dex.Write. func TestDex_WithConfig_CustomIndex(t *testing.T) { t.Parallel() rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) - cfg := &Config{ + cfg := &Settings{ Indexes: []IndexEntry{ {File: "golang.md", Summary: "Go nodes", Query: "golang"}, {File: "changes.md", Summary: "latest changes"}, // core: should be ignored }, } - dex, err := NewDexFromRepo(t.Context(), mem, WithConfig(cfg)) + dex, err := NewDexFromRepo(t.Context(), mem, WithSettings(cfg)) require.NoError(t, err) t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) @@ -361,15 +361,15 @@ func TestDex_WithConfig_BareFormCustomIndex(t *testing.T) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) - cfg := &Config{ + cfg := &Settings{ Indexes: []IndexEntry{ {File: "golang.md", Summary: "Go nodes", Query: "golang"}, }, } - dex, err := NewDexFromRepo(t.Context(), mem, WithConfig(cfg)) + dex, err := NewDexFromRepo(t.Context(), mem, WithSettings(cfg)) require.NoError(t, err) require.Len(t, dex.custom, 1) @@ -388,7 +388,7 @@ func TestDex_WithConfig_BareFormCustomIndex(t *testing.T) { func TestDex_WithConfig_CoreIndexSkipped(t *testing.T) { t.Parallel() - cfg := &Config{ + cfg := &Settings{ Indexes: []IndexEntry{ // All of these are core names and should be skipped even if Query is set. {File: "changes.md", Query: "golang"}, @@ -401,29 +401,29 @@ func TestDex_WithConfig_CoreIndexSkipped(t *testing.T) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) - dex, err := NewDexFromRepo(t.Context(), mem, WithConfig(cfg)) + dex, err := NewDexFromRepo(t.Context(), mem, WithSettings(cfg)) require.NoError(t, err) require.Empty(t, dex.custom, "core index names should not produce custom indexes") } -// TestDex_WithConfig_QueryField verifies that WithConfig reads the Query +// TestDex_WithConfig_QueryField verifies that WithSettings reads the Query // field and creates a QueryFilteredIndex. func TestDex_WithConfig_QueryField(t *testing.T) { t.Parallel() rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) - cfg := &Config{ + cfg := &Settings{ Indexes: []IndexEntry{ {File: "concepts.md", Summary: "concept nodes", Query: "golang"}, }, } - dex, err := NewDexFromRepo(t.Context(), mem, WithConfig(cfg)) + dex, err := NewDexFromRepo(t.Context(), mem, WithSettings(cfg)) require.NoError(t, err) require.Len(t, dex.custom, 1, "should create one custom index from Query field") @@ -444,13 +444,13 @@ func TestDex_WithConfig_QueryField(t *testing.T) { } // TestDex_WithQueryResolver verifies that WithQueryResolver injects a custom -// resolver into config-driven custom indexes. +// resolver into settings-driven custom indexes. func TestDex_WithQueryResolver(t *testing.T) { t.Parallel() rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) resolver := func(term string, data *NodeData) bool { // Simple resolver: treat "entity=concept" as a term match @@ -470,14 +470,14 @@ func TestDex_WithQueryResolver(t *testing.T) { return false } - cfg := &Config{ + cfg := &Settings{ Indexes: []IndexEntry{ {File: "concepts.md", Summary: "concepts", Query: "entity=concept"}, }, } - // WithQueryResolver must come before WithConfig so the resolver is available - dex, err := NewDexFromRepo(t.Context(), mem, WithQueryResolver(resolver), WithConfig(cfg)) + // WithQueryResolver must come before WithSettings so the resolver is available + dex, err := NewDexFromRepo(t.Context(), mem, WithQueryResolver(resolver), WithSettings(cfg)) require.NoError(t, err) require.Len(t, dex.custom, 1) @@ -529,7 +529,7 @@ func TestDex_ConcurrentReadWrite(t *testing.T) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - mem := NewMemoryRepo(rt) + mem := newTestMemoryRepo(rt) dex, err := NewDexFromRepo(ctx, mem) require.NoError(t, err) diff --git a/pkg/keg/errors.go b/pkg/keg/errors.go index 3fd6a2ee..c19940e0 100644 --- a/pkg/keg/errors.go +++ b/pkg/keg/errors.go @@ -9,16 +9,17 @@ import ( // Sentinel errors used for simple equality-style checks. var ( - ErrInvalid = os.ErrInvalid // invalid argument - ErrExist = os.ErrExist // file already exists - ErrNotExist = os.ErrNotExist // file does not exist - ErrPermission = os.ErrPermission // permission denied - ErrParse = errors.New("unable to parse") - ErrConflict = errors.New("conflict") - ErrQuotaExceeded = errors.New("quota exceeded") - ErrRateLimited = errors.New("rate limited") - ErrNotSupported = errors.New("not supported") - ErrSchemaInvalid = errors.New("schema validation failed") + ErrInvalid = os.ErrInvalid // invalid argument + ErrExist = os.ErrExist // file already exists + ErrNotExist = os.ErrNotExist // file does not exist + ErrPermission = os.ErrPermission // permission denied + ErrParse = errors.New("unable to parse") + ErrConflict = errors.New("conflict") + ErrPreconditionRequired = errors.New("precondition required") + ErrQuotaExceeded = errors.New("quota exceeded") + ErrRateLimited = errors.New("rate limited") + ErrNotSupported = errors.New("not supported") + ErrSchemaInvalid = errors.New("schema validation failed") // ErrInvalidAssetName is returned when a node asset name is not a single safe // path component (empty, ".", "..", contains a path separator, or absolute). @@ -48,6 +49,24 @@ var ( ErrKegLockUpgrade = errors.New("cannot upgrade keg read boundary to write") ) +// PreconditionConflictError reports an optimistic-concurrency conflict while +// preserving the current representation needed to recover and retry. It +// unwraps to ErrConflict so existing conflict checks continue to work. +type PreconditionConflictError struct { + Resource string + CurrentHash string + CurrentContent []byte +} + +func (e *PreconditionConflictError) Error() string { + if e == nil || e.Resource == "" { + return "write precondition failed: " + ErrConflict.Error() + } + return fmt.Sprintf("write precondition failed for %s: %s", e.Resource, ErrConflict) +} + +func (e *PreconditionConflictError) Unwrap() error { return ErrConflict } + // AliasNotFoundError is a typed error that carries the missing alias for callers // that need richer diagnostic information. type AliasNotFoundError struct { @@ -61,27 +80,27 @@ func NewAliasNotFoundError(alias string) error { return &AliasNotFoundError{Alias: alias} } -// InvalidConfigError represents a validation or parse failure for tapper config. -type InvalidConfigError struct { +// InvalidSettingsError represents a validation or parse failure for keg settings. +type InvalidSettingsError struct { Msg string } -func (e *InvalidConfigError) Error() string { +func (e *InvalidSettingsError) Error() string { if e.Msg == "" { - return "invalid tapper config" + return "invalid keg settings" } - return fmt.Sprintf("invalid tapper config: %s", e.Msg) + return fmt.Sprintf("invalid keg settings: %s", e.Msg) } -func (e *InvalidConfigError) Unwrap() error { return ErrInvalid } +func (e *InvalidSettingsError) Unwrap() error { return ErrInvalid } -// NewInvalidConfigError creates an InvalidConfigError with a human message. -func NewInvalidConfigError(msg string) error { - return &InvalidConfigError{Msg: msg} +// NewInvalidSettingsError creates an InvalidSettingsError with a human message. +func NewInvalidSettingsError(msg string) error { + return &InvalidSettingsError{Msg: msg} } -// IsInvalidConfig reports whether err is (or wraps) an invalid-config condition. -func IsInvalidConfig(err error) bool { +// IsInvalidSettings reports whether err is (or wraps) an invalid-settings condition. +func IsInvalidSettings(err error) bool { return errors.Is(err, ErrInvalid) } diff --git a/pkg/keg/eval_query_internal_test.go b/pkg/keg/eval_query_internal_test.go index 66e67820..9e44ad6f 100644 --- a/pkg/keg/eval_query_internal_test.go +++ b/pkg/keg/eval_query_internal_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -// makeQueryKeg creates an in-memory keg pre-populated with nodes for +// makeQueryKeg creates a sandbox-backed filesystem keg pre-populated with nodes for // evalQueryExpr unit tests. // // Nodes: @@ -25,7 +25,7 @@ func makeQueryKeg(t *testing.T) (*LocalKeg, *Dex) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - repo := NewMemoryRepo(rt) + repo := newTestMemoryRepo(rt) nodes := []struct { id int diff --git a/pkg/keg/format_boundary.go b/pkg/keg/format_boundary.go index c7386a28..dcf6bbde 100644 --- a/pkg/keg/format_boundary.go +++ b/pkg/keg/format_boundary.go @@ -4,14 +4,14 @@ import "context" // WithReadBoundary runs fn inside a single keg read boundary when k has one. // -// A local keg's read boundary is an exclusive lock, and every per-node read -// takes it. Batches of reads must therefore share one boundary rather than +// A repository-backed LocalKeg's read boundary is an exclusive lock, and every +// per-node read takes it. Batches of reads must therefore share one boundary rather than // acquiring it per call: the boundary is re-entrant through the context, so // nested reads inside fn short-circuit instead of relocking. Without this, a // listing that reads metadata for N nodes performs 2N exclusive lock cycles and // blocks every other process on the keg for the duration. // -// A remote keg has no local boundary to hold, so fn runs directly. +// A RemoteKeg has no client-side boundary to hold, so fn runs directly. func WithReadBoundary(ctx context.Context, k Keg, fn func(context.Context) error) error { local, ok := k.(*LocalKeg) if !ok || local == nil || local.Repo == nil { diff --git a/pkg/keg/format_fields_test.go b/pkg/keg/format_fields_test.go index 057f9e69..354453c2 100644 --- a/pkg/keg/format_fields_test.go +++ b/pkg/keg/format_fields_test.go @@ -245,9 +245,9 @@ func TestFormatSelectorSuggestions(t *testing.T) { func TestConfigListFieldsRoundTrip(t *testing.T) { raw := []byte("kegv: 2025-07\nlistFields:\n - id\n - type\n - subkind\n - title\n") - cfg, err := keg.ParseKegConfig(raw) + cfg, err := keg.ParseKegSettings(raw) if err != nil { - t.Fatalf("ParseKegConfig: %v", err) + t.Fatalf("ParseKegSettings: %v", err) } want := []string{"id", "type", "subkind", "title"} if len(cfg.ListFields) != len(want) { @@ -265,7 +265,7 @@ func TestConfigListFieldsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("ToYAML: %v", err) } - again, err := keg.ParseKegConfig(out) + again, err := keg.ParseKegSettings(out) if err != nil { t.Fatalf("reparse: %v", err) } @@ -275,18 +275,18 @@ func TestConfigListFieldsRoundTrip(t *testing.T) { } func TestConfigListFieldsRejectsBadSelector(t *testing.T) { - // Rejecting at parse time means a typo surfaces when the config is saved + // Rejecting at parse time means a typo surfaces when the settings is saved // rather than as a silently blank column at render time. raw := []byte("kegv: 2025-07\nlistFields:\n - type\n - .bogus\n") - if _, err := keg.ParseKegConfig(raw); err == nil { - t.Fatal("ParseKegConfig accepted an unknown stats selector, want error") + if _, err := keg.ParseKegSettings(raw); err == nil { + t.Fatal("ParseKegSettings accepted an unknown stats selector, want error") } else if !strings.Contains(err.Error(), "listFields") { t.Errorf("error = %q, want it to name the offending field", err) } } func TestConfigListFieldsEmptyIsValid(t *testing.T) { - if _, err := keg.ParseKegConfig([]byte("kegv: 2025-07\n")); err != nil { - t.Fatalf("config without listFields should parse: %v", err) + if _, err := keg.ParseKegSettings([]byte("kegv: 2025-07\n")); err != nil { + t.Fatalf("settings without listFields should parse: %v", err) } } diff --git a/pkg/keg/keg.go b/pkg/keg/keg.go index a9fdb84e..58768437 100644 --- a/pkg/keg/keg.go +++ b/pkg/keg/keg.go @@ -4,22 +4,20 @@ import ( "context" "errors" "fmt" - "path/filepath" "strings" "sync" "sync/atomic" - "time" "github.com/jlrickert/cli-toolkit/toolkit" ) // LocalKeg is the concrete high-level service providing KEG node operations // backed by a Repository. It abstracts storage implementation details, allowing -// operations over nodes to work uniformly across memory and filesystem backends. +// operations over nodes to work uniformly across repository backends. // LocalKeg delegates low-level storage operations to its underlying repository and // maintains an in-memory dex for indexing. type LocalKeg struct { - // target is the keg URL/location (nil for memory-backed kegs) + // target is the keg URL/location. target *Target // Repo is the storage backend implementation Repo Repository @@ -33,18 +31,8 @@ type LocalKeg struct { // dexWriteGen is a monotonic counter bumped after every successful // Dex.Write by this process. Used for diagnostics. dexWriteGen uint64 - // dexLoadMtime records the ModTime of dex/nodes.tsv at the time the - // cached dex was last loaded from disk. Used by dexStale() to detect - // whether another process has modified the index files since this - // process last read them. - dexLoadMtime time.Time - // dexLoadGeneration records a repository-owned in-process generation when - // the backend exposes one (MemoryRepo). It keeps caches in separate - // LocalKeg instances coherent even though filesystem mtimes do not apply. - dexLoadGeneration uint64 - - // configMu guards the read-modify-write cycle in UpdateConfig. - configMu sync.Mutex + // settingsMu guards the read-modify-write cycle in UpdateSettings. + settingsMu sync.Mutex // kegExistsVerified is set to true after the first successful // checkKegExists call. Once a keg is confirmed to exist, it won't @@ -86,9 +74,7 @@ func WithTokenResolver(r TokenResolver) KegOption { } // NewKegFromTarget constructs a Keg implementation from a Target. It automatically -// selects the appropriate repository implementation based on the target's scheme: -// - memory:// targets use an in-memory repository -// - file:// targets use a filesystem repository +// selects the appropriate remote implementation based on the target's scheme: // - http:// and https:// targets use a RemoteKeg speaking the hub's // operation API // - hub targets use a RemoteKeg resolved from repo/user/keg fields @@ -100,20 +86,6 @@ func NewKegFromTarget(ctx context.Context, target Target, rt *toolkit.Runtime, o apply(&o) } switch target.Scheme() { - case SchemeMemory: - repo := NewMemoryRepo(rt) - keg := LocalKeg{Repo: repo, Runtime: rt} - return &keg, nil - case SchemeFile: - repo := FsRepo{ - Root: filepath.Clean(target.Path()), - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - keg := LocalKeg{target: &target, Repo: &repo, Runtime: rt} - return &keg, nil case SchemeHTTP, SchemeHTTPs: token := resolveTargetToken(&target, rt, o.resolver) baseURL := strings.TrimRight(target.Url, "/") @@ -142,7 +114,7 @@ func NewKegFromTarget(ctx context.Context, target Target, rt *toolkit.Runtime, o installTokenFn(keg, &target, rt, o.resolver) return keg, nil } - return nil, fmt.Errorf("unsupported target scheme: %s", target.Scheme()) + return nil, fmt.Errorf("unsupported target scheme %q: %w", target.Scheme(), ErrNotSupported) } // installTokenFn makes k re-run the target's token resolution chain on every @@ -195,7 +167,7 @@ func NewLocalKeg(repo Repository, rt *toolkit.Runtime, opts ...Option) *LocalKeg } // RepoContainsKeg checks if a keg has been properly initialized within a repository. -// It verifies both that a keg config exists and that a zero node (node ID 0) is present. +// It verifies both that a keg settings exists and that a zero node (node ID 0) is present. // Returns true only if both conditions are met, indicating a fully initialized keg. func RepoContainsKeg(ctx context.Context, repo Repository) (bool, error) { if repo == nil { @@ -212,18 +184,18 @@ func RepoContainsKeg(ctx context.Context, repo Repository) (bool, error) { func repoContainsKeg(ctx context.Context, repo Repository) (bool, error) { - var configExists bool + var settingsExists bool - // Check for a config. If it is missing, keg is not initialized. - _, err := repo.ReadConfig(ctx) + // Check for a settings. If it is missing, keg is not initialized. + _, err := repo.ReadSettings(ctx) if err != nil { if errors.Is(err, ErrNotExist) { - configExists = false + settingsExists = false } else { - return false, fmt.Errorf("failed to check config existence: %w", err) + return false, fmt.Errorf("failed to check settings existence: %w", err) } } else { - configExists = true + settingsExists = true } var zeroNodeExists bool @@ -239,7 +211,7 @@ func repoContainsKeg(ctx context.Context, repo Repository) (bool, error) { } else { zeroNodeExists = true } - return configExists && zeroNodeExists, nil + return settingsExists && zeroNodeExists, nil } // checkKegExists verifies that a keg is properly initialized in the repository. @@ -264,8 +236,7 @@ func (k *LocalKeg) checkKegExists(ctx context.Context) error { return nil } -// Target returns the keg's resolved location, or nil for anonymous -// (memory-backed) kegs. +// Target returns the keg's resolved location, or nil when no target was set. func (k *LocalKeg) Target() *Target { if k == nil { return nil diff --git a/pkg/keg/keg_aggregate.go b/pkg/keg/keg_aggregate.go index 6654850d..aeee70e4 100644 --- a/pkg/keg/keg_aggregate.go +++ b/pkg/keg/keg_aggregate.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "slices" "sort" "strings" @@ -95,27 +96,9 @@ type RelatedNodesOptions struct { Direction RelatedDirection `json:"direction"` } -type GraphNode struct { - ID string `json:"id"` - Title string `json:"title"` - Lead string `json:"lead,omitempty"` - Tags []string `json:"tags"` -} - -type GraphEdge struct { - Source string `json:"source"` - Target string `json:"target"` - Type string `json:"type"` -} - -type GraphView struct { - Nodes []GraphNode `json:"nodes"` - Edges []GraphEdge `json:"edges"` -} - type KegInfo struct { - Config *Config `json:"config"` - Summary *KegSummary `json:"summary"` + Settings *Settings `json:"settings"` + Summary *KegSummary `json:"summary"` } type DoctorIssue struct { @@ -126,8 +109,27 @@ type DoctorIssue struct { } type RemoveNodesOptions struct { - NodeIDs []NodeId `json:"node_ids,omitempty"` - Query string `json:"query,omitempty"` + Nodes []NodeRemoveOptions `json:"nodes,omitempty"` + Query string `json:"query,omitempty"` +} + +type SettingsWriteOptions struct { + ExpectedHash string `json:"expected_hash,omitempty"` +} + +type SchemaWriteOptions struct { + ExpectedHash string `json:"expected_hash,omitempty"` +} + +type NodeMoveOptions struct { + Source NodeId `json:"source"` + Destination NodeId `json:"destination"` + ExpectedHash string `json:"expected_hash,omitempty"` +} + +type NodeRemoveOptions struct { + ID NodeId `json:"id"` + ExpectedHash string `json:"expected_hash,omitempty"` } type RemovedNode struct { @@ -141,21 +143,32 @@ type RemoveNodesResult struct { } type BatchFailure struct { - NodeID NodeId `json:"node_id"` - Code string `json:"code"` - Status int `json:"status"` - Message string `json:"message"` + NodeID NodeId `json:"node_id"` + Code string `json:"code"` + Status int `json:"status"` + Message string `json:"message"` + CurrentHash string `json:"current_hash,omitempty"` + CurrentContent []byte `json:"current_content,omitempty"` } func newBatchFailure(id NodeId, err error) *BatchFailure { code, status := RemoteErrorCode(err) - return &BatchFailure{NodeID: id, Code: code, Status: status, Message: err.Error()} + f := &BatchFailure{NodeID: id, Code: code, Status: status, Message: err.Error()} + var conflict *PreconditionConflictError + if errors.As(err, &conflict) { + f.CurrentHash = conflict.CurrentHash + f.CurrentContent = append([]byte(nil), conflict.CurrentContent...) + } + return f } func (f *BatchFailure) Err() error { if f == nil { return nil } + if f.Status == http.StatusPreconditionFailed { + return &PreconditionConflictError{Resource: f.NodeID.Path(), CurrentHash: f.CurrentHash, CurrentContent: append([]byte(nil), f.CurrentContent...)} + } return RemoteErrorFromCode(f.Code, f.Status, f.Message) } @@ -216,19 +229,6 @@ type NodeSnapshotRequest struct { Message string `json:"message,omitempty"` } -type NodeRedirect struct { - ID NodeId `json:"id"` - Target string `json:"target"` - Title string `json:"title,omitempty"` - TargetID NodeId `json:"target_id"` - ExpectedHash string `json:"expected_hash,omitempty"` -} - -type ReplaceNodesWithRedirectsResult struct { - Replaced []NodeId `json:"replaced"` - Failure *BatchFailure `json:"failure,omitempty"` -} - type DexArtifacts struct { Indexes map[string][]byte `json:"indexes"` } @@ -330,8 +330,7 @@ type fieldValues struct { // // This is the difference between a listing that costs two operations and one // that costs two per row. A backend implementing RepositoryBatchRead answers -// the whole set at once; otherwise each node is read individually, which is the -// only option for a plain filesystem keg. +// the whole set at once; otherwise each node is read individually. // // Reads are best-effort throughout: a node that is indexed but unreadable // contributes no value rather than failing the listing, because listings render @@ -597,61 +596,12 @@ func (k *LocalKeg) relatedNodes(ctx context.Context, opts RelatedNodesOptions) ( return out, nil } -func (k *LocalKeg) Graph(ctx context.Context) (*GraphView, error) { - return withKegReadValue(ctx, k, k.graph) -} - -func (k *LocalKeg) graph(ctx context.Context) (*GraphView, error) { - dex, err := k.Dex(ctx) - if err != nil { - return nil, err - } - entries := dex.Nodes(ctx) - view := &GraphView{Nodes: []GraphNode{}, Edges: []GraphEdge{}} - for _, entry := range entries { - n := GraphNode{ID: entry.ID, Title: entry.Title, Tags: []string{}} - id, parseErr := ParseNode(entry.ID) - if parseErr == nil && id != nil { - if data, readErr := k.getNodeBestEffort(ctx, *id); data != nil { - n.Tags = slices.Clone(data.Meta.Tags()) - if data.Content != nil { - n.Lead = data.Content.Lead - } - _ = readErr - } - if links, ok := dex.Links(ctx, *id); ok { - for _, dst := range links { - view.Edges = append(view.Edges, GraphEdge{Source: id.Path(), Target: dst.Path(), Type: "link"}) - } - } - if backlinks, ok := dex.Backlinks(ctx, *id); ok { - for _, source := range backlinks { - view.Edges = append(view.Edges, GraphEdge{Source: id.Path(), Target: source.Path(), Type: "backlink"}) - } - } - } - sort.Strings(n.Tags) - view.Nodes = append(view.Nodes, n) - } - sort.Slice(view.Edges, func(i, j int) bool { - a, b := view.Edges[i], view.Edges[j] - if a.Source != b.Source { - return a.Source < b.Source - } - if a.Target != b.Target { - return a.Target < b.Target - } - return a.Type < b.Type - }) - return view, nil -} - func (k *LocalKeg) Info(ctx context.Context) (*KegInfo, error) { return withKegReadValue(ctx, k, k.info) } func (k *LocalKeg) info(ctx context.Context) (*KegInfo, error) { - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { return nil, err } @@ -659,7 +609,7 @@ func (k *LocalKeg) info(ctx context.Context) (*KegInfo, error) { if err != nil { return nil, err } - return &KegInfo{Config: cfg, Summary: summary}, nil + return &KegInfo{Settings: cfg, Summary: summary}, nil } func (k *LocalKeg) Doctor(ctx context.Context) ([]DoctorIssue, error) { @@ -667,15 +617,15 @@ func (k *LocalKeg) Doctor(ctx context.Context) ([]DoctorIssue, error) { } func (k *LocalKeg) doctor(ctx context.Context) ([]DoctorIssue, error) { - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { return nil, err } issues := []DoctorIssue{} if cfg.Kegv == "" { - issues = append(issues, DoctorIssue{Level: "warning", Kind: "config", Message: "kegv version field is missing"}) - } else if cfg.Kegv != ConfigV1VersionString && cfg.Kegv != ConfigV2VersionString { - issues = append(issues, DoctorIssue{Level: "warning", Kind: "config", Message: fmt.Sprintf("unrecognized kegv version %q", cfg.Kegv)}) + issues = append(issues, DoctorIssue{Level: "warning", Kind: "settings", Message: "kegv version field is missing"}) + } else if cfg.Kegv != SettingsV1VersionString && cfg.Kegv != SettingsV2VersionString { + issues = append(issues, DoctorIssue{Level: "warning", Kind: "settings", Message: fmt.Sprintf("unrecognized kegv version %q", cfg.Kegv)}) } ids, err := k.ListNodes(ctx) if err != nil { @@ -747,9 +697,9 @@ func (k *LocalKeg) RemoveNodes(ctx context.Context, opts RemoveNodesOptions) (Re } func (k *LocalKeg) removeNodes(ctx context.Context, opts RemoveNodesOptions) (RemoveNodesResult, error) { - seen := map[string]NodeId{} - for _, id := range opts.NodeIDs { - seen[id.Path()] = id + seen := map[string]NodeRemoveOptions{} + for _, item := range opts.Nodes { + seen[item.ID.Path()] = item } if q := strings.TrimSpace(opts.Query); q != "" { entries, err := k.Query(ctx, QueryOptions{Expr: q}) @@ -758,26 +708,45 @@ func (k *LocalKeg) removeNodes(ctx context.Context, opts RemoveNodesOptions) (Re } for _, entry := range entries { if id, e := ParseNode(entry.ID); e == nil && id != nil { - seen[id.Path()] = *id + if _, explicit := seen[id.Path()]; !explicit { + view, readErr := k.ReadNode(ctx, *id) + if readErr != nil { + return RemoveNodesResult{}, readErr + } + seen[id.Path()] = NodeRemoveOptions{ID: *id, ExpectedHash: view.Hash()} + } } } } if len(seen) == 0 { return RemoveNodesResult{}, fmt.Errorf("at least one node id is required: %w", ErrInvalid) } - ids := make([]NodeId, 0, len(seen)) - for _, id := range seen { - ids = append(ids, id) + items := make([]NodeRemoveOptions, 0, len(seen)) + for _, item := range seen { + items = append(items, item) } - slices.SortFunc(ids, func(a, b NodeId) int { return a.Compare(b) }) + slices.SortFunc(items, func(a, b NodeRemoveOptions) int { return a.ID.Compare(b.ID) }) result := RemoveNodesResult{Removed: []RemovedNode{}} - for _, id := range ids { - rewritten, err := k.Remove(ctx, id) + // Preflight every item before the first mutation so a missing or stale + // token leaves the whole requested set unchanged. + for _, item := range items { + view, err := k.ReadNode(ctx, item.ID) + if err != nil { + result.Failure = newBatchFailure(item.ID, err) + return result, nil + } + if err := checkExpectedHash("node "+item.ID.Path(), item.ExpectedHash, view.Hash(), nodeRecoveryContent(view)); err != nil { + result.Failure = newBatchFailure(item.ID, err) + return result, nil + } + } + for _, item := range items { + rewritten, err := k.removeUnchecked(ctx, item.ID) if err != nil { - result.Failure = newBatchFailure(id, err) + result.Failure = newBatchFailure(item.ID, err) return result, nil } - result.Removed = append(result.Removed, RemovedNode{ID: id, Rewritten: rewritten}) + result.Removed = append(result.Removed, RemovedNode{ID: item.ID, Rewritten: rewritten}) } return result, nil } @@ -890,12 +859,9 @@ func (k *LocalKeg) updateNode(ctx context.Context, opts NodeUpdateOptions) (*Nod if err := k.validateAggregateLock(lockCtx, opts.ID, opts.LockToken); err != nil { return err } - currentHash := "" - if existing.Stats != nil { - currentHash = existing.Stats.Hash() - } - if opts.ExpectedHash != "" && opts.ExpectedHash != currentHash { - return fmt.Errorf("node %s changed: expected hash %q, got %q: %w", opts.ID.Path(), opts.ExpectedHash, currentHash, ErrConflict) + currentHash := existing.Hash() + if err := checkExpectedHash("node "+opts.ID.Path(), opts.ExpectedHash, currentHash, nodeRecoveryContent(existing)); err != nil { + return err } content, err := ParseContent(k.Runtime, opts.Content, MarkdownContentFilename) @@ -1013,48 +979,6 @@ func (k *LocalKeg) restoreTouchBackups(ctx context.Context, backups []*aggregate return errors.Join(errs...) } -func (k *LocalKeg) ReplaceNodesWithRedirects(ctx context.Context, redirects []NodeRedirect) (ReplaceNodesWithRedirectsResult, error) { - return withKegWriteValue(ctx, k, func(ctx context.Context) (ReplaceNodesWithRedirectsResult, error) { - return k.replaceNodesWithRedirects(ctx, redirects) - }) -} - -func (k *LocalKeg) replaceNodesWithRedirects(ctx context.Context, redirects []NodeRedirect) (ReplaceNodesWithRedirectsResult, error) { - result := ReplaceNodesWithRedirectsResult{Replaced: []NodeId{}} - for _, redirect := range redirects { - err := k.withNodeLock(ctx, redirect.ID, func(lockCtx context.Context) error { - view, err := k.ReadNode(lockCtx, redirect.ID) - if err != nil { - return err - } - currentHash := "" - if view.Stats != nil { - currentHash = view.Stats.Hash() - } - if redirect.ExpectedHash != "" && redirect.ExpectedHash != currentHash { - return fmt.Errorf("node %s changed before redirect: expected hash %q, got %q: %w", redirect.ID.Path(), redirect.ExpectedHash, currentHash, ErrConflict) - } - title := strings.TrimSpace(redirect.Title) - if title == "" && view.Stats != nil { - title = strings.TrimSpace(view.Stats.Title()) - } - if title == "" { - title = redirect.ID.Path() - } - body := fmt.Sprintf("# %s\n\nMoved to [%s/%s](%s/%s).\n", title, redirect.Target, redirect.TargetID.Path(), redirect.Target, redirect.TargetID.Path()) - // Redirect replacement is exempt from the live-edit schema-selection - // rule, just like move/remove link rewrites. - return k.SetContent(WithValidationMode(lockCtx, ValidationModeOff), redirect.ID, []byte(body)) - }) - if err != nil { - result.Failure = newBatchFailure(redirect.ID, err) - return result, nil - } - result.Replaced = append(result.Replaced, redirect.ID) - } - return result, nil -} - func (k *LocalKeg) DexArtifacts(ctx context.Context) (*DexArtifacts, error) { // Snapshot-derived indexes are materialized lazily for repositories created // before those artifacts existed, so the complete projection uses the write diff --git a/pkg/keg/keg_aggregate_test.go b/pkg/keg/keg_aggregate_test.go index 6b576a4b..82c55fab 100644 --- a/pkg/keg/keg_aggregate_test.go +++ b/pkg/keg/keg_aggregate_test.go @@ -15,7 +15,7 @@ import ( func TestLocalKegAggregateOperations(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, k, ctx) one, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# One\n\nlead\n"), Tags: []string{"alpha"}}) require.NoError(t, err) @@ -33,10 +33,6 @@ func TestLocalKegAggregateOperations(t *testing.T) { related, err := k.RelatedNodes(ctx, keg.RelatedNodesOptions{NodeIDs: []keg.NodeId{two.ID}, Direction: keg.RelatedLinks}) require.NoError(t, err) require.Equal(t, "1", related[0].ID) - graph, err := k.Graph(ctx) - require.NoError(t, err) - require.Len(t, graph.Nodes, 3) - require.NotEmpty(t, graph.Edges) info, err := k.Info(ctx) require.NoError(t, err) require.Equal(t, 3, info.Summary.NodeCount) @@ -60,7 +56,7 @@ func (r *failOnceContentRepo) WriteContent(ctx context.Context, id keg.NodeId, d func TestUpdateNodeRejectsStaleHashAndReturnsNewHash(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, k, ctx) created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Original\n\nbody\n")}) require.NoError(t, err) @@ -90,7 +86,7 @@ func TestUpdateNodeRejectsStaleHashAndReturnsNewHash(t *testing.T) { func TestUpdateNodeRollsBackMemoryWritesOnFailure(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - base := keg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) repo := &failOnceContentRepo{Repository: base} k := keg.NewLocalKeg(repo, fx.Runtime()) initNonStrictTestKeg(t, k, ctx) @@ -116,49 +112,28 @@ func TestUpdateNodeRollsBackMemoryWritesOnFailure(t *testing.T) { require.Equal(t, beforeIndexes.Indexes, afterIndexes.Indexes) } -func TestRemoveNodesReturnsCompletedItemsAndFailure(t *testing.T) { +func TestRemoveNodesPreflightsAllItemsBeforeMutation(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, k, ctx) created, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Remove me\n")}) require.NoError(t, err) - result, err := k.RemoveNodes(ctx, keg.RemoveNodesOptions{NodeIDs: []keg.NodeId{created.ID, {ID: 99}}}) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + result, err := k.RemoveNodes(ctx, keg.RemoveNodesOptions{Nodes: []keg.NodeRemoveOptions{ + {ID: created.ID, ExpectedHash: view.Hash()}, + {ID: keg.NodeId{ID: 99}, ExpectedHash: "missing"}, + }}) require.NoError(t, err) - require.Equal(t, []keg.NodeId{created.ID}, []keg.NodeId{result.Removed[0].ID}) + require.Empty(t, result.Removed) require.NotNil(t, result.Failure) require.Equal(t, 99, result.Failure.NodeID.ID) require.ErrorIs(t, result.Failure.Err(), keg.ErrNotExist) -} - -func TestReplaceNodesWithRedirectsReturnsCompletedItemsAndStaleFailure(t *testing.T) { - fx := NewSandbox(t) - ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) - initNonStrictTestKeg(t, k, ctx) - one, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# One\n")}) - require.NoError(t, err) - two, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Two\n")}) - require.NoError(t, err) - oneView, err := k.ReadNode(ctx, one.ID) - require.NoError(t, err) - twoView, err := k.ReadNode(ctx, two.ID) + exists, err := k.NodeExists(ctx, created.ID) require.NoError(t, err) - require.NoError(t, k.SetContent(ctx, two.ID, []byte("# Two changed\n"))) - - result, err := k.ReplaceNodesWithRedirects(ctx, []keg.NodeRedirect{ - {ID: one.ID, Target: "keg:target", TargetID: keg.NodeId{ID: 10}, ExpectedHash: oneView.Stats.Hash()}, - {ID: two.ID, Target: "keg:target", TargetID: keg.NodeId{ID: 11}, ExpectedHash: twoView.Stats.Hash()}, - }) - require.NoError(t, err) - require.Equal(t, []keg.NodeId{one.ID}, result.Replaced) - require.NotNil(t, result.Failure) - require.Equal(t, two.ID, result.Failure.NodeID) - require.ErrorIs(t, result.Failure.Err(), keg.ErrConflict) - twoAfter, err := k.ReadNode(ctx, two.ID) - require.NoError(t, err) - require.Contains(t, string(twoAfter.Content), "Two changed") + require.True(t, exists) } func TestRemoteAggregateMethodsUseOneRequest(t *testing.T) { @@ -171,8 +146,7 @@ func TestRemoteAggregateMethodsUseOneRequest(t *testing.T) { return err }}, {"doctor", http.MethodGet, "/doctor", `[]`, func(ctx context.Context, k *keg.RemoteKeg) error { _, err := k.Doctor(ctx); return err }}, - {"graph", http.MethodGet, "/graph", `{"nodes":[],"edges":[]}`, func(ctx context.Context, k *keg.RemoteKeg) error { _, err := k.Graph(ctx); return err }}, - {"info", http.MethodGet, "/info", `{"config":{"kegv":"keg.v2"},"summary":{"node_count":0}}`, func(ctx context.Context, k *keg.RemoteKeg) error { _, err := k.Info(ctx); return err }}, + {"info", http.MethodGet, "/info", `{"settings":{"kegv":"keg.v2"},"summary":{"node_count":0}}`, func(ctx context.Context, k *keg.RemoteKeg) error { _, err := k.Info(ctx); return err }}, {"read", http.MethodPost, "/nodes/read", `[]`, func(ctx context.Context, k *keg.RemoteKeg) error { _, err := k.ReadNodes(ctx, keg.ReadNodesOptions{NodeIDs: []keg.NodeId{{ID: 1}}, Touch: true}) return err @@ -181,8 +155,20 @@ func TestRemoteAggregateMethodsUseOneRequest(t *testing.T) { _, err := k.OpenNode(ctx, keg.NodeOpenOptions{ID: keg.NodeId{ID: 1}, Touch: true}) return err }}, - {"update", http.MethodPut, "/nodes/batch", `[{"id":1,"hash":"updated"}]`, func(ctx context.Context, k *keg.RemoteKeg) error { - _, err := k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: keg.NodeId{ID: 1}, Content: []byte("# One updated\n")}) + {"create one", http.MethodPost, "/nodes", `[{"key":"node","id":1,"hash":"created"}]`, func(ctx context.Context, k *keg.RemoteKeg) error { + _, err := k.Create(ctx, &keg.CreateOptions{Title: "One"}) + return err + }}, + {"update", http.MethodPut, "/nodes", `[{"id":1,"hash":"updated"}]`, func(ctx context.Context, k *keg.RemoteKeg) error { + _, err := k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: keg.NodeId{ID: 1}, Content: []byte("# One updated\n"), ExpectedHash: "current"}) + return err + }}, + {"remove one", http.MethodPost, "/nodes/remove", `{"removed":[{"id":1,"rewritten":[]}]}`, func(ctx context.Context, k *keg.RemoteKeg) error { + _, err := k.Remove(ctx, keg.NodeRemoveOptions{ID: keg.NodeId{ID: 1}, ExpectedHash: "current"}) + return err + }}, + {"snapshot one", http.MethodPost, "/nodes/snapshots", `[]`, func(ctx context.Context, k *keg.RemoteKeg) error { + _, err := k.AppendSnapshot(ctx, keg.NodeId{ID: 1}, "point") return err }}, } diff --git a/pkg/keg/keg_batch.go b/pkg/keg/keg_batch.go index 41de572e..4827af32 100644 --- a/pkg/keg/keg_batch.go +++ b/pkg/keg/keg_batch.go @@ -184,12 +184,9 @@ func (k *LocalKeg) updateNodes(ctx context.Context, updates []NodeUpdateOptions) if err := k.validateAggregateLock(ctx, opts.ID, opts.LockToken); err != nil { return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } - currentHash := "" - if existing.Stats != nil { - currentHash = existing.Stats.Hash() - } - if opts.ExpectedHash != "" && opts.ExpectedHash != currentHash { - return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: fmt.Errorf("expected hash %q, got %q: %w", opts.ExpectedHash, currentHash, ErrConflict)} + currentHash := existing.Hash() + if err := checkExpectedHash("node "+opts.ID.Path(), opts.ExpectedHash, currentHash, nodeRecoveryContent(existing)); err != nil { + return nil, &BatchMutationError{Index: i, NodeID: opts.ID, Err: err} } contentBytes := existing.Content if opts.HasContent { diff --git a/pkg/keg/keg_batch_test.go b/pkg/keg/keg_batch_test.go index 6238f51f..0a6255cd 100644 --- a/pkg/keg/keg_batch_test.go +++ b/pkg/keg/keg_batch_test.go @@ -6,36 +6,23 @@ import ( "fmt" "testing" + "github.com/jlrickert/tapper/internal/testkegrepo" "github.com/jlrickert/tapper/pkg/keg" "github.com/stretchr/testify/require" ) -type failingMemoryBatchRepo struct { - *keg.MemoryRepo +type failingBatchRepo struct { + *testkegrepo.MemoryRepository writes int failAt int } -func (r *failingMemoryBatchRepo) WriteContent(ctx context.Context, id keg.NodeId, data []byte) error { +func (r *failingBatchRepo) WriteContent(ctx context.Context, id keg.NodeId, data []byte) error { r.writes++ if r.failAt > 0 && r.writes == r.failAt { return errors.New("injected batch content failure") } - return r.MemoryRepo.WriteContent(ctx, id, data) -} - -type failingFSBatchRepo struct { - *keg.FsRepo - writes int - failAt int -} - -func (r *failingFSBatchRepo) WriteContent(ctx context.Context, id keg.NodeId, data []byte) error { - r.writes++ - if r.failAt > 0 && r.writes == r.failAt { - return errors.New("injected batch content failure") - } - return r.FsRepo.WriteContent(ctx, id, data) + return r.MemoryRepository.WriteContent(ctx, id, data) } const batchTaskSchema = `type: task @@ -52,7 +39,7 @@ func newStrictBatchKeg(t *testing.T) (*keg.LocalKeg, context.Context) { t.Helper() fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) require.NoError(t, k.Init(ctx)) require.NoError(t, k.CreateSchema(ctx, "task", []byte(batchTaskSchema))) return k, ctx @@ -90,12 +77,12 @@ func TestUpdateNodesPreflightsHashesAndSnapshotsAtomically(t *testing.T) { require.NoError(t, err) before, err := k.ReadNode(ctx, created[0].ID) require.NoError(t, err) - _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true}, {ID: created[1].ID, Schema: "task", Content: []byte("# Never\n"), HasContent: true, ExpectedHash: "stale"}}) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, ExpectedHash: before.Hash()}, {ID: created[1].ID, Schema: "task", Content: []byte("# Never\n"), HasContent: true, ExpectedHash: "stale"}}) require.ErrorIs(t, err, keg.ErrConflict) after, err := k.ReadNode(ctx, created[0].ID) require.NoError(t, err) require.Equal(t, before.Content, after.Content) - results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true}}) + results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: after.Hash()}}) require.NoError(t, err) require.NotEmpty(t, results[0].Hash) history, err := k.ListSnapshots(ctx, created[0].ID) @@ -120,84 +107,71 @@ func TestMutationBatchLimitsAndDuplicates(t *testing.T) { } func TestMutationBatchesRollbackCanonicalSnapshotsDexAndConfig(t *testing.T) { - for _, name := range []string{"memory", "filesystem"} { - t.Run(name, func(t *testing.T) { - fx := NewSandbox(t) - var repo keg.Repository - var failAt func(int) - if name == "memory" { - r := &failingMemoryBatchRepo{MemoryRepo: keg.NewMemoryRepo(fx.Runtime())} - repo = r - failAt = func(n int) { r.writes, r.failAt = 0, n } - } else { - r := &failingFSBatchRepo{FsRepo: keg.NewFsRepo("~/batch-rollback", fx.Runtime())} - repo = r - failAt = func(n int) { r.writes, r.failAt = 0, n } - } - k := keg.NewLocalKeg(repo, fx.Runtime()) - ctx := fx.Context() - require.NoError(t, k.Init(ctx)) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = false })) + fx := NewSandbox(t) + repo := &failingBatchRepo{MemoryRepository: newTestMemoryRepo(fx.Runtime())} + failAt := func(n int) { repo.writes, repo.failAt = 0, n } + k := keg.NewLocalKeg(repo, fx.Runtime()) + ctx := fx.Context() + require.NoError(t, k.Init(ctx)) + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = false })) - beforeDex, err := k.DexArtifacts(ctx) - require.NoError(t, err) - beforeCfg, err := k.Config(ctx) - require.NoError(t, err) - failAt(2) - _, err = k.CreateNodes(ctx, []keg.NodeCreate{{Key: "one", Body: []byte("# One\n")}, {Key: "two", Body: []byte("# Two\n")}}) - require.ErrorContains(t, err, "injected batch content failure") - ids, err := k.ListNodes(ctx) - require.NoError(t, err) - require.Equal(t, []keg.NodeId{{ID: 0}}, ids) - afterDex, err := k.DexArtifacts(ctx) - require.NoError(t, err) - require.Equal(t, beforeDex.Indexes, afterDex.Indexes) - afterCfg, err := k.Config(ctx) - require.NoError(t, err) - require.Equal(t, beforeCfg.Updated, afterCfg.Updated) + beforeDex, err := k.DexArtifacts(ctx) + require.NoError(t, err) + beforeCfg, err := k.Settings(ctx) + require.NoError(t, err) + failAt(2) + _, err = k.CreateNodes(ctx, []keg.NodeCreate{{Key: "one", Body: []byte("# One\n")}, {Key: "two", Body: []byte("# Two\n")}}) + require.ErrorContains(t, err, "injected batch content failure") + ids, err := k.ListNodes(ctx) + require.NoError(t, err) + require.Equal(t, []keg.NodeId{{ID: 0}}, ids) + afterDex, err := k.DexArtifacts(ctx) + require.NoError(t, err) + require.Equal(t, beforeDex.Indexes, afterDex.Indexes) + afterCfg, err := k.Settings(ctx) + require.NoError(t, err) + require.Equal(t, beforeCfg.Updated, afterCfg.Updated) - failAt(0) - created, err := k.CreateNodes(ctx, []keg.NodeCreate{{Key: "one", Body: []byte("# One\n")}, {Key: "two", Body: []byte("# Two\n")}}) - require.NoError(t, err) - beforeOne, err := k.ReadNode(ctx, created[0].ID) - require.NoError(t, err) - beforeTwo, err := k.ReadNode(ctx, created[1].ID) - require.NoError(t, err) - beforeDex, err = k.DexArtifacts(ctx) - require.NoError(t, err) - beforeCfg, err = k.Config(ctx) - require.NoError(t, err) + failAt(0) + created, err := k.CreateNodes(ctx, []keg.NodeCreate{{Key: "one", Body: []byte("# One\n")}, {Key: "two", Body: []byte("# Two\n")}}) + require.NoError(t, err) + beforeOne, err := k.ReadNode(ctx, created[0].ID) + require.NoError(t, err) + beforeTwo, err := k.ReadNode(ctx, created[1].ID) + require.NoError(t, err) + beforeDex, err = k.DexArtifacts(ctx) + require.NoError(t, err) + beforeCfg, err = k.Settings(ctx) + require.NoError(t, err) - failAt(2) - _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{ - {ID: created[0].ID, Content: []byte("# Changed one\n"), HasContent: true, SnapshotBefore: true}, - {ID: created[1].ID, Content: []byte("# Changed two\n"), HasContent: true, SnapshotBefore: true}, - }) - require.ErrorContains(t, err, "injected batch content failure") - afterOne, err := k.ReadNode(ctx, created[0].ID) - require.NoError(t, err) - afterTwo, err := k.ReadNode(ctx, created[1].ID) - require.NoError(t, err) - require.Equal(t, beforeOne.Content, afterOne.Content) - require.Equal(t, beforeTwo.Content, afterTwo.Content) - for _, item := range created { - history, historyErr := k.ListSnapshots(ctx, item.ID) - require.NoError(t, historyErr) - require.Empty(t, history) - } - afterDex, err = k.DexArtifacts(ctx) - require.NoError(t, err) - require.Equal(t, beforeDex.Indexes, afterDex.Indexes) - afterCfg, err = k.Config(ctx) - require.NoError(t, err) - require.Equal(t, beforeCfg.Updated, afterCfg.Updated) - }) + failAt(2) + _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{ + {ID: created[0].ID, Content: []byte("# Changed one\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: beforeOne.Hash()}, + {ID: created[1].ID, Content: []byte("# Changed two\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: beforeTwo.Hash()}, + }) + require.ErrorContains(t, err, "injected batch content failure") + afterOne, err := k.ReadNode(ctx, created[0].ID) + require.NoError(t, err) + afterTwo, err := k.ReadNode(ctx, created[1].ID) + require.NoError(t, err) + require.Equal(t, beforeOne.Content, afterOne.Content) + require.Equal(t, beforeTwo.Content, afterTwo.Content) + for _, item := range created { + history, historyErr := k.ListSnapshots(ctx, item.ID) + require.NoError(t, historyErr) + require.Empty(t, history) } + afterDex, err = k.DexArtifacts(ctx) + require.NoError(t, err) + require.Equal(t, beforeDex.Indexes, afterDex.Indexes) + afterCfg, err = k.Settings(ctx) + require.NoError(t, err) + require.Equal(t, beforeCfg.Updated, afterCfg.Updated) } func TestStrictPolicyUsesResolvedValidationMode(t *testing.T) { k, ctx := newStrictBatchKeg(t) - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) require.NoError(t, err) require.NotNil(t, cfg.SchemaPolicy) require.True(t, cfg.SchemaPolicy.Strict) @@ -214,12 +188,12 @@ func TestStrictPolicyUsesResolvedValidationMode(t *testing.T) { } func TestExistingConfigWithoutStrictRemainsNonStrict(t *testing.T) { - cfg, err := keg.ParseKegConfigStrict([]byte("kegv: 2025-07\nschemaPolicy:\n human: warn\n")) + cfg, err := keg.ParseKegSettingsStrict([]byte("kegv: 2025-07\nschemaPolicy:\n human: warn\n")) require.NoError(t, err) require.NotNil(t, cfg.SchemaPolicy) require.False(t, cfg.SchemaPolicy.Strict) - legacy, err := keg.ParseKegConfigStrict([]byte("kegv: 2023-01\ntitle: Legacy\n")) + legacy, err := keg.ParseKegSettingsStrict([]byte("kegv: 2023-01\ntitle: Legacy\n")) require.NoError(t, err) require.True(t, legacy.SchemaPolicy == nil || !legacy.SchemaPolicy.Strict) } @@ -227,16 +201,16 @@ func TestExistingConfigWithoutStrictRemainsNonStrict(t *testing.T) { func TestEnablingStrictDoesNotScanExistingNodes(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) require.NoError(t, k.Init(ctx)) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = false })) + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = false })) _, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Legacy one\n")}) require.NoError(t, err) _, err = k.Create(ctx, &keg.CreateOptions{Body: []byte("# Legacy two\n")}) require.NoError(t, err) - err = k.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = true }) + err = k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = true }) require.NoError(t, err) - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) require.NoError(t, err) require.True(t, cfg.SchemaPolicy.Strict) } @@ -246,6 +220,8 @@ func TestStrictSchemaChangeAndSnapshotRestoreRemainExempt(t *testing.T) { created, err := k.Create(ctx, &keg.CreateOptions{Schema: "task", Body: []byte("# Valid\n\n## Context\n")}) require.NoError(t, err) + currentSchema, err := k.ReadSchema(ctx, "task") + require.NoError(t, err) err = k.WriteSchema(ctx, "task", []byte(`type: task meta: type: object @@ -256,11 +232,13 @@ markdown: - heading: Required level: 2 required: true -`)) +`), keg.SchemaWriteOptions{ExpectedHash: keg.DocumentHash(currentSchema)}) + require.NoError(t, err) + currentSchema, err = k.ReadSchema(ctx, "task") require.NoError(t, err) - err = k.DeleteSchema(ctx, "task") + err = k.DeleteSchema(ctx, "task", keg.SchemaWriteOptions{ExpectedHash: keg.DocumentHash(currentSchema)}) require.NoError(t, err) - require.NoError(t, k.WriteSchema(ctx, "task", []byte(`type: task + require.NoError(t, k.CreateSchema(ctx, "task", []byte(`type: task meta: type: object required: [type] @@ -274,12 +252,12 @@ markdown: required: true `))) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = false })) + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = false })) require.NoError(t, k.SetContent(keg.WithValidationMode(ctx, keg.ValidationModeOff), created.ID, []byte("# Missing context\n"))) snapshot, err := k.AppendSnapshot(ctx, created.ID, "invalid legacy revision") require.NoError(t, err) require.NoError(t, k.SetContent(keg.WithValidationMode(ctx, keg.ValidationModeOff), created.ID, []byte("# Valid again\n\n## Context\n"))) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = true })) + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = true })) err = k.RestoreSnapshot(keg.WithValidationMode(ctx, keg.ValidationModeOff), created.ID, snapshot.ID) require.NoError(t, err) content, err := k.GetContent(ctx, created.ID) @@ -290,9 +268,9 @@ markdown: func TestStrictArchiveImportRemainsExempt(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - source := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + source := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) require.NoError(t, source.Init(ctx)) - require.NoError(t, source.UpdateConfig(ctx, func(cfg *keg.Config) { cfg.SchemaPolicy.Strict = false })) + require.NoError(t, source.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = false })) invalid, err := source.Create(keg.WithValidationMode(ctx, keg.ValidationModeOff), &keg.CreateOptions{Body: []byte("# Untyped legacy\n")}) require.NoError(t, err) archive, err := source.ExportNodes(ctx, keg.ExportNodesOptions{NodeIDs: []keg.NodeId{invalid.ID}, SkipZeroNode: true}) diff --git a/pkg/keg/keg_concurrent_test.go b/pkg/keg/keg_concurrent_test.go index d2934b1b..c2667510 100644 --- a/pkg/keg/keg_concurrent_test.go +++ b/pkg/keg/keg_concurrent_test.go @@ -2,9 +2,8 @@ package keg_test import ( "context" - "encoding/json" + "errors" "fmt" - "path/filepath" "sync" "testing" "time" @@ -15,12 +14,12 @@ import ( ) // TestConcurrentCreate_UniqueIDs verifies that 20 goroutines creating nodes -// concurrently via MemoryRepo all get unique IDs. +// concurrently through one repository all get unique IDs. func TestConcurrentCreate_UniqueIDs(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -53,13 +52,13 @@ func TestConcurrentCreate_UniqueIDs(t *testing.T) { } } -// TestConcurrentCreate_FsRepo verifies that 10 goroutines creating nodes -// concurrently via FsRepo sandbox all get unique IDs. -func TestConcurrentCreate_FsRepo(t *testing.T) { +// TestConcurrentCreate_MemoryRepository verifies that 10 goroutines creating nodes +// concurrently via MemoryRepository sandbox all get unique IDs. +func TestConcurrentCreate_MemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -98,7 +97,7 @@ func TestConcurrentSetContent_DifferentNodes(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -143,7 +142,7 @@ func TestConcurrentSetContent_SameNode(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -179,7 +178,7 @@ func TestConcurrentSetMeta_SameNode(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -211,7 +210,7 @@ func TestConcurrentCreateAndEdit(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -270,13 +269,13 @@ func TestConcurrentCreateAndEdit(t *testing.T) { } // TestTwoKegInstances_DexNotOverwritten verifies that two Keg instances -// sharing the same MemoryRepo do not overwrite each other's dex entries. +// sharing the same MemoryRepository do not overwrite each other's dex entries. // Reproduction test for bug 327/328 (stale dex cache in MCP server). func TestTwoKegInstances_DexNotOverwritten(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) // Create two Keg instances sharing the same repo (simulates MCP server + CLI) k1 := kegpkg.NewLocalKeg(repo, f.Runtime()) @@ -305,58 +304,11 @@ func TestTwoKegInstances_DexNotOverwritten(t *testing.T) { require.NotNil(t, ref2, "node 2 (created by k2) should be in the dex") } -// TestWithNodeLock_StaleLockRecovery writes a fake lock file with a dead PID -// and verifies that the lock is acquired after stale detection removes it. -func TestWithNodeLock_StaleLockRecovery(t *testing.T) { - t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) - require.NoError(t, err) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Locked Node"}) - require.NoError(t, err) - - // Simulate a stale lock: create the lock directory with owner.json - // containing a PID that doesn't exist (use a very high PID). - nodeDir := filepath.Join("repo", id.ID.Path()) - lockDir := filepath.Join(nodeDir, ".keg-lock") - require.NoError(t, f.Runtime().Mkdir(lockDir, 0o700, false)) - - staleLock := struct { - PID int `json:"pid"` - Hostname string `json:"hostname"` - StartedAt string `json:"started_at"` - UID string `json:"uid"` - }{ - PID: 999999999, // Very unlikely to be alive. - Hostname: "testhost", - StartedAt: "2025-01-01T00:00:00Z", - UID: "stale-uid", - } - data, err := json.Marshal(staleLock) - require.NoError(t, err) - ownerPath := filepath.Join(lockDir, "owner.json") - require.NoError(t, f.Runtime().WriteFile(ownerPath, data, 0o644)) - - // Now attempt a lock operation — it should detect the stale lock and succeed. - err = k.SetContent(f.Context(), id.ID, []byte("# Updated after stale lock\n")) - require.NoError(t, err, "SetContent should succeed after stale lock recovery") - - // Verify content was updated. - content, err := k.GetContent(f.Context(), id.ID) - require.NoError(t, err) - require.Equal(t, "# Updated after stale lock\n", string(content)) -} - -// TestConcurrentCrossLock_OnlyOneWins verifies that concurrent AcquireLock -// calls on the same node result in exactly one winner, with the rest timing out. func TestConcurrentCrossLock_OnlyOneWins(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -395,7 +347,7 @@ func TestCrossLock_DoesNotBlockWithNodeLock(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -421,98 +373,6 @@ func TestCrossLock_DoesNotBlockWithNodeLock(t *testing.T) { require.NoError(t, repo.ReleaseLock(f.Context(), id.ID, token)) } -// TestConcurrentRemoveDuringSetContent_MemoryRepo verifies that if a node is -// removed while SetContent is about to write, SetContent returns ErrNotExist -// and does not resurrect the node. This is a regression test for issue 325. -func TestConcurrentRemoveDuringSetContent_MemoryRepo(t *testing.T) { - t.Parallel() - f := NewSandbox(t) - - repo := kegpkg.NewMemoryRepo(f.Runtime()) - k := kegpkg.NewLocalKeg(repo, f.Runtime()) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Doomed"}) - require.NoError(t, err) - - // Remove the node. - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) - - // SetContent after removal should fail with ErrNotExist. - err = k.SetContent(f.Context(), id.ID, []byte("# Resurrected\n")) - require.Error(t, err) - require.ErrorIs(t, err, kegpkg.ErrNotExist) - - // Verify the node was not resurrected. - exists, err := repo.HasNode(f.Context(), id.ID) - require.NoError(t, err) - require.False(t, exists, "node should not be resurrected after removal") -} - -// TestConcurrentRemoveDuringSetMeta_MemoryRepo verifies that SetMeta on a -// removed node returns ErrNotExist and does not resurrect it. -func TestConcurrentRemoveDuringSetMeta_MemoryRepo(t *testing.T) { - t.Parallel() - f := NewSandbox(t) - - repo := kegpkg.NewMemoryRepo(f.Runtime()) - k := kegpkg.NewLocalKeg(repo, f.Runtime()) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{ - Title: "Meta Doomed", - Tags: []string{"victim"}, - }) - require.NoError(t, err) - - // Read meta before removal. - meta, err := k.GetMeta(f.Context(), id.ID) - require.NoError(t, err) - - // Remove the node. - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) - - // SetMeta after removal should fail with ErrNotExist. - meta.SetTags([]string{"ghost"}) - err = k.SetMeta(f.Context(), id.ID, meta) - require.Error(t, err) - require.ErrorIs(t, err, kegpkg.ErrNotExist) - - // Verify the node was not resurrected. - exists, err := repo.HasNode(f.Context(), id.ID) - require.NoError(t, err) - require.False(t, exists, "node should not be resurrected by SetMeta") -} - -// TestConcurrentRemoveDuringUpdateMeta_MemoryRepo verifies that UpdateMeta -// on a removed node returns ErrNotExist. -func TestConcurrentRemoveDuringUpdateMeta_MemoryRepo(t *testing.T) { - t.Parallel() - f := NewSandbox(t) - - repo := kegpkg.NewMemoryRepo(f.Runtime()) - k := kegpkg.NewLocalKeg(repo, f.Runtime()) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Update Doomed"}) - require.NoError(t, err) - - // Remove the node. - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) - - // UpdateMeta after removal should fail with ErrNotExist. - err = k.UpdateMeta(f.Context(), id.ID, func(m *kegpkg.NodeMeta) { - m.SetTags([]string{"ghost"}) - }) - require.Error(t, err) - require.ErrorIs(t, err, kegpkg.ErrNotExist) - - // Verify the node was not resurrected. - exists, err := repo.HasNode(f.Context(), id.ID) - require.NoError(t, err) - require.False(t, exists, "node should not be resurrected by UpdateMeta") -} - // TestConcurrentRemoveThenSetContent_RaceCondition runs Remove and // SetContent concurrently to verify the node lock serializes them and // prevents resurrection. @@ -520,7 +380,7 @@ func TestConcurrentRemoveThenSetContent_RaceCondition(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -533,7 +393,7 @@ func TestConcurrentRemoveThenSetContent_RaceCondition(t *testing.T) { wg.Add(2) go func() { defer wg.Done() - _, removeErr = k.Remove(f.Context(), id.ID) + _, removeErr = k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)) }() go func() { defer wg.Done() @@ -548,14 +408,13 @@ func TestConcurrentRemoveThenSetContent_RaceCondition(t *testing.T) { // In neither case should the node be resurrected after Remove completes. if removeErr == nil { // Remove succeeded. SetContent either succeeded (ran first) or - // failed with ErrNotExist (ran second). + // failed because the node disappeared or its precondition became stale. if setErr != nil { - require.ErrorIs(t, setErr, kegpkg.ErrNotExist) + require.True(t, errors.Is(setErr, kegpkg.ErrNotExist) || errors.Is(setErr, kegpkg.ErrConflict), setErr) } } else { - // Remove failed (e.g., SetContent removed the lock dir). Either - // way the node should not be in a resurrected broken state. - require.ErrorIs(t, removeErr, kegpkg.ErrNotExist) + // A concurrent write may make the remove precondition stale. + require.True(t, errors.Is(removeErr, kegpkg.ErrNotExist) || errors.Is(removeErr, kegpkg.ErrConflict), removeErr) } // After everything settles, if the node exists it should have valid content. @@ -572,13 +431,13 @@ func TestConcurrentRemoveThenSetContent_RaceCondition(t *testing.T) { require.NotNil(t, content, "surviving node should have content") } -// TestConcurrentRemoveDuringSetContent_FsRepo verifies the same +// TestConcurrentRemoveDuringSetContent_MemoryRepository verifies the same // anti-resurrection behavior on the filesystem-backed repository. -func TestConcurrentRemoveDuringSetContent_FsRepo(t *testing.T) { +func TestConcurrentRemoveDuringSetContent_MemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -586,7 +445,7 @@ func TestConcurrentRemoveDuringSetContent_FsRepo(t *testing.T) { require.NoError(t, err) // Remove the node. - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)))) // SetContent after removal should fail with ErrNotExist. err = k.SetContent(f.Context(), id.ID, []byte("# FsResurrected\n")) @@ -601,13 +460,13 @@ func TestConcurrentRemoveDuringSetContent_FsRepo(t *testing.T) { require.False(t, exists, "bare directory should be cleaned up after failed write") } -// TestConcurrentRemoveDuringSetMeta_FsRepo verifies anti-resurrection for +// TestConcurrentRemoveDuringSetMeta_MemoryRepository verifies anti-resurrection for // SetMeta on the filesystem-backed repository. -func TestConcurrentRemoveDuringSetMeta_FsRepo(t *testing.T) { +func TestConcurrentRemoveDuringSetMeta_MemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_meta")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_meta"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_meta"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -620,7 +479,7 @@ func TestConcurrentRemoveDuringSetMeta_FsRepo(t *testing.T) { meta, err := k.GetMeta(f.Context(), id.ID) require.NoError(t, err) - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)))) meta.SetTags([]string{"ghost"}) err = k.SetMeta(f.Context(), id.ID, meta) @@ -633,89 +492,18 @@ func TestConcurrentRemoveDuringSetMeta_FsRepo(t *testing.T) { require.False(t, exists, "bare directory should be cleaned up after failed SetMeta") } -// TestSetContent_NoOrphanedDirectoryOnRemovedNode verifies that after -// SetContent returns ErrNotExist for a removed node, no empty node directory -// is left behind on disk. This is a defense-in-depth check ensuring the -// WithNodeLock cleanup and WriteContent existence check cooperate to prevent -// orphaned artifacts. -func TestSetContent_NoOrphanedDirectoryOnRemovedNode(t *testing.T) { - t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) - require.NoError(t, err) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Ephemeral"}) - require.NoError(t, err) - - // Verify node directory exists before removal. - nodeDir := filepath.Join("repo", id.ID.Path()) - _, statErr := f.Runtime().Stat(nodeDir, false) - require.NoError(t, statErr, "node directory should exist after Create") - - // Remove the node. - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) - - // Verify directory was removed. - _, statErr = f.Runtime().Stat(nodeDir, false) - require.Error(t, statErr, "node directory should not exist after Remove") - - // Attempt SetContent — should fail with ErrNotExist. - err = k.SetContent(f.Context(), id.ID, []byte("# Ghost content\n")) - require.Error(t, err) - require.ErrorIs(t, err, kegpkg.ErrNotExist) - - // Verify no orphaned empty directory was left behind. WithNodeLock - // creates the directory as a lock artifact and should clean it up - // when the lock callback returns without creating a content file. - _, statErr = f.Runtime().Stat(nodeDir, false) - require.Error(t, statErr, "no orphaned directory should remain after failed SetContent") - - // Also verify via HasNode for consistency. - exists, err := k.(*kegpkg.LocalKeg).Repo.HasNode(f.Context(), id.ID) - require.NoError(t, err) - require.False(t, exists, "HasNode should return false — no resurrection") -} - -// TestConcurrentRemoveDuringTouch_MemoryRepo verifies that Touch on a removed -// node returns ErrNotExist and does not resurrect the node. -func TestConcurrentRemoveDuringTouch_MemoryRepo(t *testing.T) { - t.Parallel() - f := NewSandbox(t) - - repo := kegpkg.NewMemoryRepo(f.Runtime()) - k := kegpkg.NewLocalKeg(repo, f.Runtime()) - initNonStrictTestKeg(t, k, f.Context()) - - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Touch Doomed"}) - require.NoError(t, err) - - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) - - err = k.Touch(f.Context(), id.ID) - require.Error(t, err) - require.ErrorIs(t, err, kegpkg.ErrNotExist) - - exists, err := repo.HasNode(f.Context(), id.ID) - require.NoError(t, err) - require.False(t, exists, "node should not be resurrected by Touch") -} - -// TestConcurrentRemoveDuringTouch_FsRepo verifies that Touch on a removed -// node returns ErrNotExist on the filesystem-backed repository. -func TestConcurrentRemoveDuringTouch_FsRepo(t *testing.T) { +func TestConcurrentRemoveDuringTouch_MemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "FsTouchDoomed"}) require.NoError(t, err) - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)))) err = k.Touch(f.Context(), id.ID) require.Error(t, err) @@ -726,20 +514,20 @@ func TestConcurrentRemoveDuringTouch_FsRepo(t *testing.T) { require.False(t, exists, "bare directory should be cleaned up after failed Touch") } -// TestConcurrentRemoveDuringUpdateMeta_FsRepo verifies that UpdateMeta on a +// TestConcurrentRemoveDuringUpdateMeta_MemoryRepository verifies that UpdateMeta on a // removed node returns ErrNotExist on the filesystem-backed repository. -func TestConcurrentRemoveDuringUpdateMeta_FsRepo(t *testing.T) { +func TestConcurrentRemoveDuringUpdateMeta_MemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "FsUpdateDoomed"}) require.NoError(t, err) - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)))) err = k.(*kegpkg.LocalKeg).UpdateMeta(f.Context(), id.ID, func(m *kegpkg.NodeMeta) { m.SetTags([]string{"ghost"}) diff --git a/pkg/keg/keg_helpers.go b/pkg/keg/keg_helpers.go index a56bdd00..4c23e56a 100644 --- a/pkg/keg/keg_helpers.go +++ b/pkg/keg/keg_helpers.go @@ -6,21 +6,21 @@ import ( "time" ) -// UpdateConfig applies f to the keg's configuration via a read-then-set over -// the Keg interface. Unlike LocalKeg.UpdateConfig this is not atomic — a +// UpdateSettings applies f to the keg's configuration via a read-then-set over +// the Keg interface. Unlike LocalKeg.UpdateSettings this is not atomic — a // concurrent writer between the read and the set is lost — which is an // accepted trade-off for rare admin operations over remote kegs. -func UpdateConfig(ctx context.Context, k Keg, f func(*Config)) error { - cfg, err := k.Config(ctx) +func UpdateSettings(ctx context.Context, k Keg, f func(*Settings)) error { + cfg, err := k.Settings(ctx) if err != nil { - return fmt.Errorf("unable to read keg config: %w", err) + return fmt.Errorf("unable to read keg settings: %w", err) } f(cfg) - return k.SetConfig(ctx, []byte(cfg.String())) + return k.SetSettings(ctx, []byte(cfg.String()), SettingsWriteOptions{ExpectedHash: cfg.Hash()}) } // UpdateMeta applies f to a node's metadata via read-then-set over the Keg -// interface. Not atomic across concurrent writers; see UpdateConfig. +// interface. Not atomic across concurrent writers; see UpdateSettings. func UpdateMeta(ctx context.Context, k Keg, id NodeId, f func(*NodeMeta)) error { meta, err := k.GetMeta(ctx, id) if err != nil { diff --git a/pkg/keg/keg_iface.go b/pkg/keg/keg_iface.go index 18a50958..7155e59a 100644 --- a/pkg/keg/keg_iface.go +++ b/pkg/keg/keg_iface.go @@ -12,7 +12,7 @@ import ( // // Two implementations exist: // -// - LocalKeg orchestrates a Repository (FsRepo, MemoryRepo, or the hub's +// - LocalKeg orchestrates a Repository (MemoryRepository or the hub's // PgRepo) and maintains derived state itself. // - RemoteKeg speaks the tapper-hub operation API; each method is a single // HTTP round trip and all orchestration happens server-side. @@ -21,23 +21,22 @@ import ( // files, images, snapshots, locks, and events) return ErrNotSupported when the // backend lacks the capability. type Keg interface { - // Target returns the keg's resolved location, or nil for anonymous - // (memory-backed) kegs. + // Target returns the keg's resolved location, or nil when no target was set. Target() *Target - // Init bootstraps an empty keg: config file plus zero node. Remote kegs + // Init bootstraps an empty keg: settings file plus zero node. Remote kegs // are created through the hub's keg-creation endpoint instead and return // ErrNotSupported. Init(ctx context.Context) error - // Config returns the keg-level configuration (the `keg` file). - Config(ctx context.Context) (*Config, error) + // Settings returns the keg-level configuration (the `keg` file). + Settings(ctx context.Context) (*Settings, error) - // SetConfig replaces the keg configuration with the supplied raw YAML. + // SetSettings replaces the keg settings with the supplied raw YAML. // Raw bytes preserve user formatting for round-trip editing. - SetConfig(ctx context.Context, data []byte) error + SetSettings(ctx context.Context, data []byte, opts SettingsWriteOptions) error - // Info returns the keg configuration and summary from one coherent + // Info returns the keg settings and summary from one coherent // keg-wide read snapshot. Info(ctx context.Context) (*KegInfo, error) @@ -47,9 +46,10 @@ type Keg interface { // ReadSchema returns the raw YAML definition for typeName. ReadSchema(ctx context.Context, typeName string) ([]byte, error) - // WriteSchema validates and stores the YAML definition for typeName, - // replacing any existing definition. - WriteSchema(ctx context.Context, typeName string, data []byte) error + // WriteSchema validates and updates the existing YAML definition for + // typeName. It returns ErrNotExist when the schema does not exist; use + // CreateSchema for creation. + WriteSchema(ctx context.Context, typeName string, data []byte, opts SchemaWriteOptions) error // CreateSchema validates and stores the YAML definition for typeName only // when it does not exist. Concurrent creators are serialized so exactly one @@ -57,7 +57,7 @@ type Keg interface { CreateSchema(ctx context.Context, typeName string, data []byte) error // DeleteSchema removes the definition for typeName. - DeleteSchema(ctx context.Context, typeName string) error + DeleteSchema(ctx context.Context, typeName string, opts SchemaWriteOptions) error // ValidateNode validates the stored content and metadata for id against its // declared schema without changing the node. @@ -90,11 +90,11 @@ type Keg interface { // Move relocates src to dst and rewrites inbound links. It returns the // ids of nodes whose content was rewritten to follow the move. - Move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, error) + Move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, error) // Remove deletes a node and rewrites or drops inbound links. It returns // the ids of nodes whose content was rewritten. - Remove(ctx context.Context, id NodeId) ([]NodeId, error) + Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) // Commit promotes a temporary, code-backed node to a permanent numeric id. // It is a no-op for an already-permanent node. @@ -126,12 +126,6 @@ type Keg interface { // after preflighting every lock, hash, payload, and schema result. UpdateNodes(ctx context.Context, updates []NodeUpdateOptions) ([]NodeUpdateResult, error) - // ReplaceNodesWithRedirects replaces nodes with redirect stubs in input - // order, checking each optional expected hash. It stops at the first failure - // and returns both the successful prefix and a Failure; completed replacements - // are not rolled back. - ReplaceNodesWithRedirects(ctx context.Context, redirects []NodeRedirect) (ReplaceNodesWithRedirectsResult, error) - // GetContent returns the node's primary content (README.md). GetContent(ctx context.Context, id NodeId) ([]byte, error) @@ -183,11 +177,6 @@ type Keg interface { // is missing, or the direction is invalid. RelatedNodes(ctx context.Context, opts RelatedNodesOptions) ([]NodeIndexEntry, error) - // Graph returns a deterministic graph projection of the keg's dex. - // - // Deprecated: Tapper Hub supersedes local graph rendering. - Graph(ctx context.Context) (*GraphView, error) - // Doctor inspects configuration, content, links, metadata, stats, and schema // validation and returns deterministic diagnostic issues without mutating the // keg. @@ -318,6 +307,22 @@ type NodeView struct { // capability. Files []string Images []string + hash string +} + +// Hash is the node's precondition token: the value a caller echoes back as +// NodeUpdateOptions.ExpectedHash so a write is rejected when the node changed +// after it was read. It covers content *and* metadata (see nodeStateHash), so +// a content edit and a metadata edit on one node correctly conflict with each +// other. An empty result means the node has neither yet. +func (v NodeView) Hash() string { + if v.hash != "" { + return v.hash + } + if v.Stats == nil { + return "" + } + return v.Stats.Hash() } // QueryOptions configures Keg.Query. diff --git a/pkg/keg/keg_iface_test.go b/pkg/keg/keg_iface_test.go index 2ea72371..ad038bd9 100644 --- a/pkg/keg/keg_iface_test.go +++ b/pkg/keg/keg_iface_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -// newLiftedKeg returns an initialized memory-backed keg with two linked, +// newLiftedKeg returns an initialized filesystem-backed keg with two linked, // tagged nodes for exercising the lifted Keg interface operations. func newLiftedKeg(t *testing.T) (*sandbox.Sandbox, *kegpkg.LocalKeg) { t.Helper() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -43,7 +43,7 @@ func TestReadNodeAssemblesFullState(t *testing.T) { require.Equal(t, kegpkg.NodeId{ID: 1}, view.ID) require.Contains(t, string(view.Content), "Alpha body") require.NotNil(t, view.Stats) - // MemoryRepo supports assets/images, so the lists must be non-nil. + // MemoryRepository supports assets/images, so the lists must be non-nil. require.NotNil(t, view.Files) require.NotNil(t, view.Images) } @@ -125,7 +125,7 @@ func TestExportImportRoundTrip(t *testing.T) { require.NotEmpty(t, archive) // Import into a fresh keg. - repo2 := kegpkg.NewMemoryRepo(f.Runtime()) + repo2 := newTestMemoryRepo(f.Runtime()) k2 := kegpkg.NewLocalKeg(repo2, f.Runtime()) initNonStrictTestKeg(t, k2, f.Context()) @@ -169,7 +169,7 @@ func TestMoveReturnsRewrittenNodes(t *testing.T) { f, k := newLiftedKeg(t) // Node 1 links to ../2; moving 2 -> 5 must rewrite node 1. - rewritten, err := k.Move(f.Context(), kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 5}) + rewritten, err := k.Move(f.Context(), moveOptions(t, f.Context(), k, kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 5})) require.NoError(t, err) require.Contains(t, rewritten, kegpkg.NodeId{ID: 1}) diff --git a/pkg/keg/keg_listview_batch_test.go b/pkg/keg/keg_listview_batch_test.go index 77fe8d86..b0d7c3fb 100644 --- a/pkg/keg/keg_listview_batch_test.go +++ b/pkg/keg/keg_listview_batch_test.go @@ -77,7 +77,7 @@ func TestListViewBatchesMetadataReads(t *testing.T) { const nodeCount = 12 fx := NewSandbox(t) - base := kegpkg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) counter := &countingRepo{Repository: base} k := kegpkg.NewLocalKeg(&batchingRepo{countingRepo: counter}, fx.Runtime()) initNonStrictTestKeg(t, k, context.Background()) @@ -104,7 +104,7 @@ func TestListViewSortBatchesMetadataReads(t *testing.T) { const nodeCount = 12 fx := NewSandbox(t) - base := kegpkg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) counter := &countingRepo{Repository: base} k := kegpkg.NewLocalKeg(&batchingRepo{countingRepo: counter}, fx.Runtime()) initNonStrictTestKeg(t, k, context.Background()) @@ -143,7 +143,7 @@ func TestListViewFallsBackWithoutBatchCapability(t *testing.T) { const nodeCount = 6 fx := NewSandbox(t) - base := kegpkg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) counter := &countingRepo{Repository: base} k := kegpkg.NewLocalKeg(counter, fx.Runtime()) initNonStrictTestKeg(t, k, context.Background()) @@ -165,7 +165,7 @@ func TestListViewIntrinsicsReadNothing(t *testing.T) { t.Parallel() fx := NewSandbox(t) - base := kegpkg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) counter := &countingRepo{Repository: base} k := kegpkg.NewLocalKeg(&batchingRepo{countingRepo: counter}, fx.Runtime()) initNonStrictTestKeg(t, k, context.Background()) diff --git a/pkg/keg/keg_local_config.go b/pkg/keg/keg_local_config.go deleted file mode 100644 index ed084f49..00000000 --- a/pkg/keg/keg_local_config.go +++ /dev/null @@ -1,156 +0,0 @@ -package keg - -import ( - "bytes" - "context" - "errors" - "fmt" - "path/filepath" - "time" - - "gopkg.in/yaml.v3" -) - -// Config returns the keg's configuration. -func (k *LocalKeg) Config(ctx context.Context) (*Config, error) { - return withKegReadValue(ctx, k, k.config) -} - -func (k *LocalKeg) config(ctx context.Context) (*Config, error) { - if err := k.checkKegExists(ctx); err != nil { - return nil, fmt.Errorf("failed to retrieve config: %w", err) - } - - return k.Repo.ReadConfig(ctx) -} - -// UpdateConfig reads the keg config, applies the provided mutation function, -// and writes the result back to the repository. This is the preferred way to -// modify keg configuration to ensure updates are atomically persisted. -func (k *LocalKeg) UpdateConfig(ctx context.Context, f func(*Config)) error { - return k.withKegWrite(ctx, func(ctx context.Context) error { return k.updateConfig(ctx, f) }) -} - -func (k *LocalKeg) updateConfig(ctx context.Context, f func(*Config)) error { - if err := k.checkKegExists(ctx); err != nil { - return fmt.Errorf("unable to update config: %w", err) - } - - k.configMu.Lock() - defer k.configMu.Unlock() - - // Read config directly from the repository to allow InitKeg to create it when - // the keg is not yet fully initiated. - cfg, err := k.Repo.ReadConfig(ctx) - if err != nil { - if errors.Is(err, ErrNotExist) { - cfg = NewConfig() - } else { - return fmt.Errorf("failed to read config: %w", err) - } - } - f(cfg) - if err := k.Repo.WriteConfig(ctx, cfg); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - return nil -} - -// SetConfig parses and writes keg configuration from raw bytes. -// Prefer UpdateConfig for most use cases as it handles read-modify-write atomically. -func (k *LocalKeg) SetConfig(ctx context.Context, data []byte) error { - return k.withKegWrite(ctx, func(ctx context.Context) error { return k.setConfig(ctx, data) }) -} - -func (k *LocalKeg) setConfig(ctx context.Context, data []byte) error { - if err := k.checkKegExists(ctx); err != nil { - return fmt.Errorf("unable to set config: %w", err) - } - cfg, err := ParseKegConfigStrict(data) - if err != nil { - return fmt.Errorf("unable to parse config: %w", err) - } - if err := k.Repo.WriteConfig(ctx, cfg); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - return nil -} - -func (k *LocalKeg) touchConfigUpdated(ctx context.Context, at time.Time) error { - if at.IsZero() { - at = k.Runtime.Clock().Now() - } - updated := at.Format(time.RFC3339) - - if fsRepo, ok := k.Repo.(*FsRepo); ok { - return fsRepoTouchConfigUpdated(fsRepo, updated) - } - - return k.UpdateConfig(ctx, func(cfg *Config) { - cfg.Updated = updated - }) -} - -func fsRepoTouchConfigUpdated(repo *FsRepo, updated string) error { - configPath, raw, err := fsRepoReadRawConfig(repo) - if err != nil { - return err - } - - patched, err := patchConfigUpdatedField(raw, updated) - if err != nil { - return fmt.Errorf("failed to patch config timestamp: %w", err) - } - - if bytes.Equal(raw, patched) { - return nil - } - if err := repo.runtime.AtomicWriteFile(configPath, patched, 0o644); err != nil { - return NewBackendError(repo.Name(), "WriteConfig", 0, err, false) - } - return nil -} - -func fsRepoReadRawConfig(repo *FsRepo) (string, []byte, error) { - candidates := []string{"keg", "keg.yaml", "keg.yml"} - for _, candidate := range candidates { - path := filepath.Join(repo.Root, candidate) - if _, err := repo.runtime.Stat(path, false); err == nil { - b, readErr := repo.runtime.ReadFile(path) - if readErr != nil { - return "", nil, NewBackendError(repo.Name(), "ReadConfig", 0, readErr, false) - } - return path, b, nil - } - } - return "", nil, ErrNotExist -} - -func patchConfigUpdatedField(raw []byte, updated string) ([]byte, error) { - var doc yaml.Node - if err := yaml.Unmarshal(raw, &doc); err != nil { - return nil, err - } - if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { - return nil, fmt.Errorf("config root must be a mapping") - } - - root := doc.Content[0] - for i := 0; i+1 < len(root.Content); i += 2 { - key := root.Content[i] - if key.Kind == yaml.ScalarNode && key.Value == "updated" { - val := root.Content[i+1] - val.Kind = yaml.ScalarNode - val.Tag = "!!str" - val.Style = 0 - val.Value = updated - return yaml.Marshal(&doc) - } - } - - root.Content = append(root.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "updated"}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: updated}, - ) - return yaml.Marshal(&doc) -} diff --git a/pkg/keg/keg_local_content.go b/pkg/keg/keg_local_content.go index 01fe61e3..0930a769 100644 --- a/pkg/keg/keg_local_content.go +++ b/pkg/keg/keg_local_content.go @@ -40,7 +40,7 @@ func (k *LocalKeg) setContentNoDexWithOptions(ctx context.Context, id NodeId, da err := k.withNodeLock(ctx, id, func(lockCtx context.Context) error { // Verify the node truly exists (has content) under the lock to // prevent resurrecting a concurrently removed node. HasNode - // alone is not enough for FsRepo because WithNodeLock creates + // alone is not enough for MemoryRepository because WithNodeLock creates // the node directory as a side effect. exists, err := k.nodeExistsWithContent(lockCtx, id) if err != nil { @@ -160,7 +160,7 @@ func (k *LocalKeg) getStatsValue(ctx context.Context, id NodeId) (*NodeStats, er // SetMeta writes metadata for a node and updates the dex. // If the new meta bytes are identical to the existing on-disk meta, -// the write and dex/config update are skipped entirely. +// the write and dex/settings update are skipped entirely. func (k *LocalKeg) SetMeta(ctx context.Context, id NodeId, meta *NodeMeta) error { return k.SetMetaWithOptions(ctx, id, meta, NodeWriteOptions{}) } @@ -252,7 +252,7 @@ func (k *LocalKeg) setMeta(ctx context.Context, id NodeId, meta *NodeMeta, opts return err } - // nodeData is nil when meta was unchanged — skip dex and config update. + // nodeData is nil when meta was unchanged — skip dex and settings update. if nodeData == nil { return nil } diff --git a/pkg/keg/keg_local_create.go b/pkg/keg/keg_local_create.go index 0780d2e4..20e86130 100644 --- a/pkg/keg/keg_local_create.go +++ b/pkg/keg/keg_local_create.go @@ -9,7 +9,7 @@ import ( "github.com/jlrickert/cli-toolkit/toolkit" ) -// Init initializes a new keg by creating the config file, zero node with default +// Init initializes a new keg by creating the settings file, zero node with default // content, and updating the dex. It returns an error if the keg already exists. // Init is idempotent in the sense that it checks for existing kegs first. func (k *LocalKeg) Init(ctx context.Context) error { @@ -30,11 +30,11 @@ func (k *LocalKeg) init(ctx context.Context) error { return fmt.Errorf("keg already exists: %w", ErrExist) } - // Ensure we have a config file. UpdateConfig must be allowed to write the - // repo-level config even when the keg is not fully initiated. - cfg := NewConfig() - if err := k.Repo.WriteConfig(ctx, cfg); err != nil { - return fmt.Errorf("failed to write config: %w", err) + // Ensure we have a settings file. UpdateSettings must be allowed to write the + // repo-level settings even when the keg is not fully initiated. + cfg := NewSettings() + if err := k.Repo.WriteSettings(ctx, cfg); err != nil { + return fmt.Errorf("failed to write settings: %w", err) } // Create the zero node as a special case during InitKeg. We do this here so diff --git a/pkg/keg/keg_local_dex.go b/pkg/keg/keg_local_dex.go index adb2e6b6..21ee1db3 100644 --- a/pkg/keg/keg_local_dex.go +++ b/pkg/keg/keg_local_dex.go @@ -4,16 +4,12 @@ import ( "context" "errors" "fmt" - "path/filepath" "time" ) -// Dex returns the keg's index with always-fresh semantics: the cached dex is -// reused only while it is provably current (see dexStale), otherwise it is -// reloaded from the repository. Config-driven query-filtered indexes are -// applied automatically via WithConfig. Safe for both short-lived CLI -// invocations and long-lived processes (serve handlers, MCP servers) where -// another process may update the index between calls. +// Dex returns the keg's current index. Repository-backed indexes are reloaded +// for every aggregate read so a long-lived hub process does not serve a stale +// view after another process or replica updates the repository. func (k *LocalKeg) Dex(ctx context.Context) (*Dex, error) { return withKegReadValue(ctx, k, k.readDex) } @@ -25,103 +21,48 @@ func (k *LocalKeg) readDex(ctx context.Context) (*Dex, error) { return k.ensureDexFresh(ctx) } -// dexOptions reads the keg config and returns DexOptions to apply when -// constructing or initialising a Dex. If the config is absent or cannot be +// dexOptions reads the keg settings and returns DexOptions to apply when +// constructing or initialising a Dex. If the settings is absent or cannot be // read, an empty (nil) slice is returned so callers can proceed without error. func (k *LocalKeg) dexOptions(ctx context.Context) ([]DexOption, error) { - cfg, err := k.Repo.ReadConfig(ctx) + cfg, err := k.Repo.ReadSettings(ctx) if err != nil { if errors.Is(err, ErrNotExist) { return nil, nil } return nil, err } - return []DexOption{WithConfig(cfg)}, nil + return []DexOption{WithSettings(cfg)}, nil } -// -- private utility functions - -// indexFileMtime returns the ModTime of dex/nodes.tsv for FsRepo backends. -// For non-filesystem repos (e.g. MemoryRepo) it returns time.Time{} (zero). -func (k *LocalKeg) indexFileMtime() time.Time { - fsRepo, ok := k.Repo.(*FsRepo) - if !ok { - return time.Time{} - } - idxPath := filepath.Join(fsRepo.Root, "dex", "nodes.tsv") - info, err := fsRepo.runtime.Stat(idxPath, false) - if err != nil { - return time.Time{} - } - return info.ModTime() -} - -// dexStale reports whether the cached dex is out of date. Generation-aware -// repositories such as MemoryRepo compare kegOperationGeneration with -// dexLoadGeneration. FsRepo compares the current mtime of dex/nodes.tsv with -// the mtime recorded when the dex was last loaded. Other repositories are -// treated as external and conservatively remain stale. -// -// Caller must hold k.dexMu. -func (k *LocalKeg) dexStale() bool { - if generation, ok := k.Repo.(interface{ kegOperationGeneration() uint64 }); ok { - return generation.kegOperationGeneration() != k.dexLoadGeneration - } - if _, ok := k.Repo.(*FsRepo); !ok { - return true - } - current := k.indexFileMtime() - if current.IsZero() { - // File doesn't exist — treat as stale so we rebuild. - return true - } - return !current.Equal(k.dexLoadMtime) -} - -// ensureDexFresh returns the cached dex if it is still current, otherwise -// reloads it from disk. This replaces the pattern of InvalidateDex() + -// Dex(ctx) which unconditionally discarded the cache. -// -// ensureDexFresh acquires k.dexMu internally; callers must NOT hold it. +// ensureDexFresh reloads repository index artifacts under the dex mutex. func (k *LocalKeg) ensureDexFresh(ctx context.Context) (*Dex, error) { k.dexMu.Lock() defer k.dexMu.Unlock() - if k.dex != nil && !k.dexStale() { - return k.dex, nil - } - opts, _ := k.dexOptions(ctx) dex, err := NewDexFromRepo(ctx, k.Repo, opts...) k.dex = dex - k.dexLoadMtime = k.indexFileMtime() - if generation, ok := k.Repo.(interface{ kegOperationGeneration() uint64 }); ok { - k.dexLoadGeneration = generation.kegOperationGeneration() - } return dex, err } -// recordDexWrite updates the mtime cache and generation counter after a -// successful Dex.Write. Caller must hold k.dexMu. +// recordDexWrite updates the generation counter after a successful Dex.Write. +// Caller must hold k.dexMu. func (k *LocalKeg) recordDexWrite() { - k.dexLoadMtime = k.indexFileMtime() - if generation, ok := k.Repo.(interface{ kegOperationGeneration() uint64 }); ok { - k.dexLoadGeneration = generation.kegOperationGeneration() - } k.dexWriteGen++ } // writeNodeToDex adds or updates a node in the dex, persists dex artifacts, -// records the write, and touches the keg config updated timestamp. When -// updatedAt is zero, the runtime clock is used for the config timestamp. +// records the write, and touches the keg settings updated timestamp. When +// updatedAt is zero, the runtime clock is used for the settings timestamp. func (k *LocalKeg) writeNodeToDex(ctx context.Context, data *NodeData, updatedAt time.Time) error { return k.writeNodesToDex(ctx, []*NodeData{data}, updatedAt) } // writeNodesToDex updates several nodes in one in-memory dex generation and // persists the generated indexes once. Existing entries are retained, which -// is important for older filesystem KEGs whose dex may contain normalized -// stats that have not yet been split into stats.json files. +// is important for repositories whose dex may contain normalized stats that +// have not yet been split into distinct stats records. func (k *LocalKeg) writeNodesToDex(ctx context.Context, nodes []*NodeData, updatedAt time.Time) error { if len(nodes) == 0 { return nil @@ -147,8 +88,8 @@ func (k *LocalKeg) writeNodesToDex(ctx context.Context, nodes []*NodeData, updat if updatedAt.IsZero() { updatedAt = k.Runtime.Clock().Now() } - if err := k.touchConfigUpdated(ctx, updatedAt); err != nil { - return fmt.Errorf("failed to touch keg config after dex write for node %s: %w", firstID, err) + if err := k.touchSettingsUpdated(ctx, updatedAt); err != nil { + return fmt.Errorf("failed to touch keg settings after dex write for node %s: %w", firstID, err) } return nil } diff --git a/pkg/keg/keg_local_index.go b/pkg/keg/keg_local_index.go index 8c458727..a8ec2b92 100644 --- a/pkg/keg/keg_local_index.go +++ b/pkg/keg/keg_local_index.go @@ -63,7 +63,7 @@ func (k *LocalKeg) indexAll(ctx context.Context, opts IndexOptions) error { k.dexMu.Lock() if k.dex == nil { k.dex = &Dex{} - // Apply config-driven options (e.g. tag-filtered indexes) to the new Dex. + // Apply settings-driven options (e.g. tag-filtered indexes) to the new Dex. dexOpts, _ := k.dexOptions(ctx) for _, opt := range dexOpts { _ = opt(k.dex) @@ -150,7 +150,7 @@ func (k *LocalKeg) indexAll(ctx context.Context, opts IndexOptions) error { k.recordDexWrite() k.dexMu.Unlock() } - if err := k.touchConfigUpdated(ctx, now); err != nil { + if err := k.touchSettingsUpdated(ctx, now); err != nil { errs = append(errs, fmt.Errorf("failed to update index timestamp: %w", err)) } if err := k.refreshSnapshotGeneratedIndexes(ctx); err != nil { diff --git a/pkg/keg/keg_local_move.go b/pkg/keg/keg_local_move.go index e855f14c..36bc840a 100644 --- a/pkg/keg/keg_local_move.go +++ b/pkg/keg/keg_local_move.go @@ -10,29 +10,36 @@ import ( // Move renames a node from src to dst and rewrites in-content links that // target src (../N) across the keg. It returns the ids of nodes whose content // was rewritten to follow the move. -func (k *LocalKeg) Move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, error) { - return withKegWriteValue(ctx, k, func(ctx context.Context) ([]NodeId, error) { return k.move(ctx, src, dst) }) +func (k *LocalKeg) Move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, error) { + return withKegWriteValue(ctx, k, func(ctx context.Context) ([]NodeId, error) { return k.move(ctx, opts) }) } -func (k *LocalKeg) move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, error) { +func (k *LocalKeg) move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, error) { if err := k.checkKegExists(ctx); err != nil { return nil, fmt.Errorf("failed to move node: %w", err) } - src = NodeId{ID: src.ID, Code: src.Code} - dst = NodeId{ID: dst.ID, Code: dst.Code} + src := NodeId{ID: opts.Source.ID, Code: opts.Source.Code} + dst := NodeId{ID: opts.Destination.ID, Code: opts.Destination.Code} if !src.Valid() || !dst.Valid() { return nil, fmt.Errorf("invalid node id: %w", ErrInvalid) } if src.ID == 0 || dst.ID == 0 { return nil, fmt.Errorf("node 0 cannot be moved: %w", ErrInvalid) } + current, err := k.ReadNode(ctx, src) + if err != nil { + return nil, err + } + if err := checkExpectedHash("node "+src.Path(), opts.ExpectedHash, current.Hash(), nodeRecoveryContent(current)); err != nil { + return nil, err + } if src.Equals(dst) { return nil, nil } // Use content-aware existence checks so shadow reservations created by - // FsRepo.Next() / FsRepo.WithNodeLock() do not masquerade as real nodes. + // MemoryRepository.Next() / MemoryRepository.WithNodeLock() do not masquerade as real nodes. // These are pre-lock gates; the under-lock authoritative check runs // inside Repo.MoveNode. srcExists, err := k.nodeExistsWithContent(ctx, src) @@ -126,8 +133,8 @@ func (k *LocalKeg) move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, } now := k.Runtime.Clock().Now() - if err := k.touchConfigUpdated(ctx, now); err != nil { - errs = append(errs, fmt.Errorf("failed to update config after move: %w", err)) + if err := k.touchSettingsUpdated(ctx, now); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after move: %w", err)) } if err := k.refreshSnapshotGeneratedIndexes(ctx); err != nil { errs = append(errs, fmt.Errorf("failed to refresh snapshot indexes after move: %w", err)) @@ -140,19 +147,36 @@ func (k *LocalKeg) move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, return rewritten, errors.Join(errs...) } -// Remove deletes a node from the repository and updates dex/config artifacts. +// Remove deletes a node from the repository and updates dex/settings artifacts. // It returns the ids of nodes whose content was rewritten to drop links to the // removed node. -func (k *LocalKeg) Remove(ctx context.Context, id NodeId) ([]NodeId, error) { - return withKegWriteValue(ctx, k, func(ctx context.Context) ([]NodeId, error) { return k.remove(ctx, id) }) +func (k *LocalKeg) Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) { + return withKegWriteValue(ctx, k, func(ctx context.Context) ([]NodeId, error) { return k.remove(ctx, opts) }) } -func (k *LocalKeg) remove(ctx context.Context, id NodeId) ([]NodeId, error) { +func (k *LocalKeg) remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) { if err := k.checkKegExists(ctx); err != nil { return nil, fmt.Errorf("failed to remove node: %w", err) } - id = NodeId{ID: id.ID, Code: id.Code} + id := NodeId{ID: opts.ID.ID, Code: opts.ID.Code} + if !id.Valid() { + return nil, fmt.Errorf("invalid node id: %w", ErrInvalid) + } + if id.ID == 0 { + return nil, fmt.Errorf("node 0 cannot be removed: %w", ErrInvalid) + } + current, err := k.ReadNode(ctx, id) + if err != nil { + return nil, err + } + if err := checkExpectedHash("node "+id.Path(), opts.ExpectedHash, current.Hash(), nodeRecoveryContent(current)); err != nil { + return nil, err + } + return k.removeUnchecked(ctx, id) +} + +func (k *LocalKeg) removeUnchecked(ctx context.Context, id NodeId) ([]NodeId, error) { if !id.Valid() { return nil, fmt.Errorf("invalid node id: %w", ErrInvalid) } @@ -163,7 +187,7 @@ func (k *LocalKeg) remove(ctx context.Context, id NodeId) ([]NodeId, error) { // Check existence before acquiring the lock. WithNodeLock will also // return ErrNotExist for missing nodes, but this check provides a // clearer error message. Use the content-aware helper so shadow - // reservations (bare directories from FsRepo.Next() / WithNodeLock) + // reservations (bare directories from MemoryRepository.Next() / WithNodeLock) // are not mistaken for real nodes. exists, err := k.nodeExistsWithContent(ctx, id) if err != nil { @@ -241,8 +265,8 @@ func (k *LocalKeg) remove(ctx context.Context, id NodeId) ([]NodeId, error) { } now := k.Runtime.Clock().Now() - if err := k.touchConfigUpdated(ctx, now); err != nil { - errs = append(errs, fmt.Errorf("failed to update config after remove: %w", err)) + if err := k.touchSettingsUpdated(ctx, now); err != nil { + errs = append(errs, fmt.Errorf("failed to update settings after remove: %w", err)) } if err := k.refreshSnapshotGeneratedIndexes(ctx); err != nil { errs = append(errs, fmt.Errorf("failed to refresh snapshot indexes after remove: %w", err)) diff --git a/pkg/keg/keg_local_node.go b/pkg/keg/keg_local_node.go index fd0ade1f..811ba724 100644 --- a/pkg/keg/keg_local_node.go +++ b/pkg/keg/keg_local_node.go @@ -16,7 +16,7 @@ func (k *LocalKeg) withNodeLock(ctx context.Context, id NodeId, fn func(context. } // nodeExistsWithContent checks whether a node truly exists by verifying it has -// content (or at minimum an entry in the repo). For FsRepo, WithNodeLock +// content (or at minimum an entry in the repo). For MemoryRepository, WithNodeLock // creates a bare directory as a side effect of lock acquisition, so HasNode // alone is insufficient — a bare directory without README.md is not a real // node. This helper reads the content file to confirm the node was properly diff --git a/pkg/keg/keg_local_settings.go b/pkg/keg/keg_local_settings.go new file mode 100644 index 00000000..44345b96 --- /dev/null +++ b/pkg/keg/keg_local_settings.go @@ -0,0 +1,155 @@ +package keg + +import ( + "context" + "errors" + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +// Settings returns the keg's configuration. +func (k *LocalKeg) Settings(ctx context.Context) (*Settings, error) { + return withKegReadValue(ctx, k, k.settings) +} + +func (k *LocalKeg) settings(ctx context.Context) (*Settings, error) { + if err := k.checkKegExists(ctx); err != nil { + return nil, fmt.Errorf("failed to retrieve settings: %w", err) + } + + if store, ok := k.Repo.(RepositorySettingsDocuments); ok { + raw, err := store.ReadSettingsDocument(ctx) + if err != nil { + return nil, err + } + cfg, err := ParseKegSettings(raw) + if err != nil { + return nil, err + } + cfg.setDocument(raw, DocumentHash(raw)) + return cfg, nil + } + cfg, err := k.Repo.ReadSettings(ctx) + if err != nil { + return nil, err + } + raw, err := cfg.ToYAML() + if err == nil { + cfg.setDocument(raw, DocumentHash(raw)) + } + return cfg, err +} + +// UpdateSettings reads the keg settings, applies the provided mutation function, +// and writes the result back to the repository. This is the preferred way to +// modify keg settings to ensure updates are atomically persisted. +func (k *LocalKeg) UpdateSettings(ctx context.Context, f func(*Settings)) error { + return k.withKegWrite(ctx, func(ctx context.Context) error { return k.updateSettings(ctx, f) }) +} + +func (k *LocalKeg) updateSettings(ctx context.Context, f func(*Settings)) error { + if err := k.checkKegExists(ctx); err != nil { + return fmt.Errorf("unable to update settings: %w", err) + } + + k.settingsMu.Lock() + defer k.settingsMu.Unlock() + + // Read settings directly from the repository to allow InitKeg to create it when + // the keg is not yet fully initiated. + cfg, err := k.Repo.ReadSettings(ctx) + if err != nil { + if errors.Is(err, ErrNotExist) { + cfg = NewSettings() + } else { + return fmt.Errorf("failed to read settings: %w", err) + } + } + f(cfg) + if err := k.Repo.WriteSettings(ctx, cfg); err != nil { + return fmt.Errorf("failed to write settings: %w", err) + } + return nil +} + +// SetSettings parses and writes keg settings from raw bytes. +// Prefer UpdateSettings for most use cases as it handles read-modify-write atomically. +func (k *LocalKeg) SetSettings(ctx context.Context, data []byte, opts SettingsWriteOptions) error { + return k.withKegWrite(ctx, func(ctx context.Context) error { return k.setSettings(ctx, data, opts) }) +} + +func (k *LocalKeg) setSettings(ctx context.Context, data []byte, opts SettingsWriteOptions) error { + if err := k.checkKegExists(ctx); err != nil { + return fmt.Errorf("unable to set settings: %w", err) + } + current, err := k.settings(ctx) + if err != nil { + return fmt.Errorf("failed to read current settings: %w", err) + } + if err := checkExpectedHash("settings", opts.ExpectedHash, current.Hash(), current.Raw()); err != nil { + return err + } + return k.replaceSettings(ctx, data) +} + +// replaceSettings is reserved for operations such as archive restore that own +// the complete keg write boundary and intentionally sit outside user edit +// preconditions. +func (k *LocalKeg) replaceSettings(ctx context.Context, data []byte) error { + cfg, err := ParseKegSettingsStrict(data) + if err != nil { + return fmt.Errorf("unable to parse settings: %w", err) + } + if store, ok := k.Repo.(RepositorySettingsDocuments); ok { + if err := store.WriteSettingsDocument(ctx, data); err != nil { + return fmt.Errorf("failed to write settings: %w", err) + } + return nil + } + if err := k.Repo.WriteSettings(ctx, cfg); err != nil { + return fmt.Errorf("failed to write settings: %w", err) + } + return nil +} + +func (k *LocalKeg) touchSettingsUpdated(ctx context.Context, at time.Time) error { + if at.IsZero() { + at = k.Runtime.Clock().Now() + } + updated := at.Format(time.RFC3339) + + return k.UpdateSettings(ctx, func(cfg *Settings) { + cfg.Updated = updated + }) +} + +func patchSettingsUpdatedField(raw []byte, updated string) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("settings root must be a mapping") + } + + root := doc.Content[0] + for i := 0; i+1 < len(root.Content); i += 2 { + key := root.Content[i] + if key.Kind == yaml.ScalarNode && key.Value == "updated" { + val := root.Content[i+1] + val.Kind = yaml.ScalarNode + val.Tag = "!!str" + val.Style = 0 + val.Value = updated + return yaml.Marshal(&doc) + } + } + + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "updated"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: updated}, + ) + return yaml.Marshal(&doc) +} diff --git a/pkg/keg/keg_local_view.go b/pkg/keg/keg_local_view.go index 4e9d90f5..9ba0c323 100644 --- a/pkg/keg/keg_local_view.go +++ b/pkg/keg/keg_local_view.go @@ -45,6 +45,15 @@ func (k *LocalKeg) readNode(ctx context.Context, id NodeId) (*NodeView, error) { Meta: meta, Stats: stats, } + if view.Stats.Hash() == "" { + parsedContent, parseErr := ParseContent(k.Runtime, content, MarkdownContentFilename) + if parseErr == nil { + parsedMeta, metaErr := ParseMeta(ctx, meta) + if metaErr == nil { + view.hash = nodeStateHash(k.Runtime, parsedContent.Hash, parsedMeta) + } + } + } if files, ok := k.Repo.(RepositoryFiles); ok { names, err := files.ListFiles(ctx, id) if err != nil && !errors.Is(err, ErrNotExist) { @@ -69,8 +78,8 @@ func (k *LocalKeg) readNode(ctx context.Context, id NodeId) (*NodeView, error) { } // NodeExists reports whether id is a fully written node (content present), as -// opposed to a bare reservation directory left behind by FsRepo.Next() or -// FsRepo.WithNodeLock(). It holds no node lock; mutating operations re-check +// opposed to a bare reservation directory left behind by MemoryRepository.Next() or +// MemoryRepository.WithNodeLock(). It holds no node lock; mutating operations re-check // under lock. func (k *LocalKeg) NodeExists(ctx context.Context, id NodeId) (bool, error) { return withKegReadValue(ctx, k, func(ctx context.Context) (bool, error) { diff --git a/pkg/keg/keg_operation_test.go b/pkg/keg/keg_operation_test.go index 21f614b4..cf8617a2 100644 --- a/pkg/keg/keg_operation_test.go +++ b/pkg/keg/keg_operation_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "sync" "sync/atomic" "testing" @@ -54,22 +53,10 @@ func exerciseOperationBoundary(t *testing.T, repo keg.Repository) { require.NoError(t, <-done) } -func TestMemoryRepoKegOperationBoundary(t *testing.T) { +func TestMemoryRepositoryKegOperationBoundary(t *testing.T) { fx := NewSandbox(t) - exerciseOperationBoundary(t, keg.NewMemoryRepo(fx.Runtime())) -} - -func TestFsRepoKegOperationBoundaryAndStaleOwnerCleanup(t *testing.T) { - fx := NewSandbox(t) - repo := keg.NewFsRepo("repo", fx.Runtime()) + repo := newTestMemoryRepo(fx.Runtime()) exerciseOperationBoundary(t, repo) - - lockPath := filepath.Join("repo", keg.KegOperationLock) - require.NoError(t, fx.Runtime().Mkdir(lockPath, 0o700, true)) - require.NoError(t, fx.Runtime().WriteFile(filepath.Join(lockPath, "owner.json"), []byte(`{"pid":2147483647,"hostname":"stale","started_at":"2000-01-01T00:00:00Z","uid":"test"}`), 0o600)) - require.NoError(t, repo.WithKegRead(fx.Context(), func(context.Context) error { return nil })) - _, err := fx.Runtime().Stat(lockPath, false) - require.Error(t, err) } type dexPhaseRepo struct { @@ -115,7 +102,7 @@ func (r *dexPhaseRepo) WriteIndex(ctx context.Context, name string, data []byte) func TestDifferentNodeWritersSerializeCompleteDexPersistence(t *testing.T) { fx := NewSandbox(t) - base := keg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) repo := newDexPhaseRepo(base) first := keg.NewLocalKeg(repo, fx.Runtime()) second := keg.NewLocalKeg(repo, fx.Runtime()) @@ -150,7 +137,7 @@ func TestDifferentNodeWritersSerializeCompleteDexPersistence(t *testing.T) { func TestConcurrentMutationsMatchCleanDexRebuild(t *testing.T) { fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) + repo := newTestMemoryRepo(fx.Runtime()) first := keg.NewLocalKeg(repo, fx.Runtime()) second := keg.NewLocalKeg(repo, fx.Runtime()) initNonStrictTestKeg(t, first, fx.Context()) @@ -176,7 +163,7 @@ func TestConcurrentMutationsMatchCleanDexRebuild(t *testing.T) { }() go func() { <-start - _, err := first.Remove(fx.Context(), removeTarget.ID) + _, err := first.Remove(fx.Context(), removeOptions(t, fx.Context(), first, removeTarget.ID)) errs <- err }() go func() { @@ -239,7 +226,7 @@ func (r *snapshotPhaseRepo) ReadContent(ctx context.Context, id keg.NodeId) ([]b func TestAggregateReadCannotMixNodeGenerations(t *testing.T) { fx := NewSandbox(t) - base := keg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) repo := &snapshotPhaseRepo{ Repository: base, readEntered: make(chan struct{}), @@ -282,7 +269,7 @@ func TestAggregateReadCannotMixNodeGenerations(t *testing.T) { func TestOperationBoundaryRejectsNilCallback(t *testing.T) { fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) + repo := newTestMemoryRepo(fx.Runtime()) require.Error(t, repo.WithKegRead(context.Background(), nil)) require.Error(t, repo.WithKegWrite(context.Background(), nil)) require.False(t, errors.Is(repo.WithKegWrite(context.Background(), nil), keg.ErrKegLockUpgrade)) diff --git a/pkg/keg/keg_query.go b/pkg/keg/keg_query.go index 73b92bbf..de632f3b 100644 --- a/pkg/keg/keg_query.go +++ b/pkg/keg/keg_query.go @@ -572,7 +572,7 @@ func matchFloat(fieldVal float64, op string, compareVal float64) bool { // defaultNodeQueryResolver evaluates a single query term against a node's // data: key=value terms check meta attributes, plain terms check the tag set. -// It is the default resolver for config-driven query-filtered indexes; a +// It is the default resolver for settings-driven query-filtered indexes; a // custom resolver may be injected with WithQueryResolver. func defaultNodeQueryResolver(term string, data *NodeData) bool { if data == nil { diff --git a/pkg/keg/keg_remote.go b/pkg/keg/keg_remote.go index 1eee4076..c16b6227 100644 --- a/pkg/keg/keg_remote.go +++ b/pkg/keg/keg_remote.go @@ -31,8 +31,11 @@ var ( // apiErrorEnvelope is the JSON error envelope returned by tapper-hub: // {"error": msg, "code": CODE}. type apiErrorEnvelope struct { - Error string `json:"error"` - Code string `json:"code"` + Error string `json:"error"` + Code string `json:"code"` + OperationPerformed bool `json:"operationPerformed"` + CurrentHash string `json:"currentHash"` + CurrentContent string `json:"currentContent"` } // RemoteKeg implements [Keg] over tapper-hub's operation-level HTTP API. @@ -145,6 +148,10 @@ func (k *RemoteKeg) do(ctx context.Context, method, path string, body io.Reader, req.Header.Add(key, v) } } + if orientation, ok := OrientationHeaderValue(ctx); ok { + // Trusted session state wins over any operation-specific header. + req.Header.Set(OrientationHeaderName, orientation) + } for key, val := range ValidationHeaderValues(ctx) { if req.Header.Get(key) == "" { req.Header.Set(key, val) @@ -194,6 +201,13 @@ func (k *RemoteKeg) mapError(resp *http.Response, op string) error { } return NewRateLimitError(retryAfter, msg, nil) } + if resp.StatusCode == http.StatusPreconditionFailed { + return &PreconditionConflictError{ + Resource: where, + CurrentHash: env.CurrentHash, + CurrentContent: []byte(env.CurrentContent), + } + } // A 404 with no parseable envelope (e.g. a HEAD response or a proxy // error page) still means the resource is absent. @@ -287,7 +301,7 @@ func (k *RemoteKeg) jsonRequest(ctx context.Context, method, path, op string, in return nil } -// --- Init / config --- +// --- Init / settings --- // Init implements Keg. Remote kegs are created through the hub's // keg-creation endpoint (POST /api/v1/@{namespace}/kegs) at the Tap layer, @@ -296,22 +310,37 @@ func (k *RemoteKeg) Init(ctx context.Context) error { return fmt.Errorf("remote keg init: use the hub keg-creation endpoint: %w", ErrNotSupported) } -// Config implements Keg via GET /config. -func (k *RemoteKeg) Config(ctx context.Context) (*Config, error) { - cfg := &Config{} - if err := k.getJSON(ctx, "/config", "Config", cfg); err != nil { +// Settings implements Keg via GET /settings. +func (k *RemoteKeg) Settings(ctx context.Context) (*Settings, error) { + resp, err := k.do(ctx, http.MethodGet, "/settings", nil, "", nil) + if err != nil { + return nil, err + } + raw, err := k.readBody(resp, "Settings", http.StatusOK) + if err != nil { + return nil, err + } + cfg, err := ParseKegSettings(raw) + if err != nil { return nil, err } + hash := strings.Trim(resp.Header.Get("ETag"), `"`) + if hash == "" { + hash = DocumentHash(raw) + } + cfg.setDocument(raw, hash) return cfg, nil } -// SetConfig implements Keg via PUT /config with the raw config bytes. -func (k *RemoteKeg) SetConfig(ctx context.Context, data []byte) error { - resp, err := k.do(ctx, http.MethodPut, "/config", bytes.NewReader(data), "application/octet-stream", nil) +// SetSettings implements Keg via PUT /settings with the raw settings bytes. +func (k *RemoteKeg) SetSettings(ctx context.Context, data []byte, opts SettingsWriteOptions) error { + header := make(http.Header) + header.Set("If-Match", opts.ExpectedHash) + resp, err := k.do(ctx, http.MethodPut, "/settings", bytes.NewReader(data), "application/octet-stream", header) if err != nil { return err } - _, err = k.readBody(resp, "SetConfig", http.StatusOK, http.StatusNoContent) + _, err = k.readBody(resp, "SetSettings", http.StatusOK, http.StatusNoContent) return err } @@ -334,8 +363,10 @@ func (k *RemoteKeg) ReadSchema(ctx context.Context, typeName string) ([]byte, er } // WriteSchema implements Keg via PUT /schemas/{type}. -func (k *RemoteKeg) WriteSchema(ctx context.Context, typeName string, data []byte) error { - resp, err := k.do(ctx, http.MethodPut, "/schemas/"+url.PathEscape(typeName), bytes.NewReader(data), "application/yaml", nil) +func (k *RemoteKeg) WriteSchema(ctx context.Context, typeName string, data []byte, opts SchemaWriteOptions) error { + header := make(http.Header) + header.Set("If-Match", opts.ExpectedHash) + resp, err := k.do(ctx, http.MethodPut, "/schemas/"+url.PathEscape(typeName), bytes.NewReader(data), "application/yaml", header) if err != nil { return err } @@ -344,8 +375,10 @@ func (k *RemoteKeg) WriteSchema(ctx context.Context, typeName string, data []byt } // DeleteSchema implements Keg via DELETE /schemas/{type}. -func (k *RemoteKeg) DeleteSchema(ctx context.Context, typeName string) error { - resp, err := k.do(ctx, http.MethodDelete, "/schemas/"+url.PathEscape(typeName), nil, "", nil) +func (k *RemoteKeg) DeleteSchema(ctx context.Context, typeName string, opts SchemaWriteOptions) error { + header := make(http.Header) + header.Set("If-Match", opts.ExpectedHash) + resp, err := k.do(ctx, http.MethodDelete, "/schemas/"+url.PathEscape(typeName), nil, "", header) if err != nil { return err } @@ -458,37 +491,34 @@ func parseRewritten(paths []string) ([]NodeId, error) { } // Move implements Keg via POST /nodes/{src}/move. -func (k *RemoteKeg) Move(ctx context.Context, src NodeId, dst NodeId) ([]NodeId, error) { +func (k *RemoteKeg) Move(ctx context.Context, opts NodeMoveOptions) ([]NodeId, error) { var result struct { Rewritten []string `json:"rewritten"` } req := struct { - Dst int `json:"dst"` - }{Dst: dst.ID} - path := fmt.Sprintf("/nodes/%d/move", src.ID) + Dst int `json:"dst"` + ExpectedHash string `json:"expected_hash"` + }{Dst: opts.Destination.ID, ExpectedHash: opts.ExpectedHash} + path := fmt.Sprintf("/nodes/%d/move", opts.Source.ID) if err := k.postJSON(ctx, path, "Move", req, &result, http.StatusOK); err != nil { return nil, err } return parseRewritten(result.Rewritten) } -// Remove implements Keg via DELETE /nodes/{id}. -func (k *RemoteKeg) Remove(ctx context.Context, id NodeId) ([]NodeId, error) { - resp, err := k.do(ctx, http.MethodDelete, fmt.Sprintf("/nodes/%d", id.ID), nil, "", nil) +// Remove implements Keg as a batch-of-one call to POST /nodes/remove. +func (k *RemoteKeg) Remove(ctx context.Context, opts NodeRemoveOptions) ([]NodeId, error) { + result, err := k.RemoveNodes(ctx, RemoveNodesOptions{Nodes: []NodeRemoveOptions{opts}}) if err != nil { return nil, err } - body, err := k.readBody(resp, "Remove", http.StatusOK) - if err != nil { - return nil, err + if result.Failure != nil { + return nil, result.Failure.Err() } - var result struct { - Rewritten []string `json:"rewritten"` + if len(result.Removed) != 1 { + return nil, NewBackendError("remote", "Remove", 0, fmt.Errorf("invalid response: expected one removed node, got %d", len(result.Removed)), false) } - if err := json.Unmarshal(body, &result); err != nil { - return nil, NewBackendError("remote", "Remove", 0, fmt.Errorf("invalid response: %w", err), false) - } - return parseRewritten(result.Rewritten) + return result.Removed[0].Rewritten, nil } // Commit implements Keg via POST /nodes/{id}/commit. @@ -606,9 +636,9 @@ func (k *RemoteKeg) GetStats(ctx context.Context, id NodeId) (*NodeStats, error) // --- Listing, query, and index --- -// Dex implements Keg via GET /dex, which returns every index artifact in -// one response. The artifacts are loaded into a scratch in-memory repo and -// parsed through the same NewDexFromRepo path LocalKeg uses. +// Dex implements Keg via GET /dex, which returns every index artifact in one +// response. The artifacts are parsed through the same index reader used by +// LocalKeg without constructing a repository. func (k *RemoteKeg) Dex(ctx context.Context) (*Dex, error) { var result struct { Indexes map[string]string `json:"indexes"` @@ -616,13 +646,7 @@ func (k *RemoteKeg) Dex(ctx context.Context) (*Dex, error) { if err := k.getJSON(ctx, "/dex", "Dex", &result); err != nil { return nil, err } - scratch := NewMemoryRepo(k.rt) - for name, content := range result.Indexes { - if err := scratch.WriteIndex(ctx, name, []byte(content)); err != nil { - return nil, NewBackendError("remote", "Dex", 0, err, false) - } - } - return NewDexFromRepo(ctx, scratch) + return newDexFromIndexReader(ctx, indexMapReader(result.Indexes)) } // Query implements Keg via POST /query. @@ -662,12 +686,12 @@ func (k *RemoteKeg) Grep(ctx context.Context, opts GrepOptions) ([]GrepMatch, er return out, nil } -// Index implements Keg via POST /index/rebuild. +// Index implements Keg via POST /indexes/rebuild. func (k *RemoteKeg) Index(ctx context.Context, opts IndexOptions) error { req := struct { NoUpdate bool `json:"no_update"` }{NoUpdate: opts.NoUpdate} - return k.postJSON(ctx, "/index/rebuild", "Index", req, nil, http.StatusOK, http.StatusNoContent) + return k.postJSON(ctx, "/indexes/rebuild", "Index", req, nil, http.StatusOK, http.StatusNoContent) } // ListIndexes implements Keg via GET /indexes. diff --git a/pkg/keg/keg_remote_aggregate.go b/pkg/keg/keg_remote_aggregate.go index 1dcfb620..c4a2e62d 100644 --- a/pkg/keg/keg_remote_aggregate.go +++ b/pkg/keg/keg_remote_aggregate.go @@ -102,13 +102,6 @@ func (k *RemoteKeg) RelatedNodes(ctx context.Context, opts RelatedNodesOptions) return out.Entries, nil } -func (k *RemoteKeg) Graph(ctx context.Context) (*GraphView, error) { - var out GraphView - if err := k.getJSON(ctx, "/graph", "Graph", &out); err != nil { - return nil, err - } - return &out, nil -} func (k *RemoteKeg) Info(ctx context.Context) (*KegInfo, error) { var out KegInfo if err := k.getJSON(ctx, "/info", "Info", &out); err != nil { @@ -125,24 +118,33 @@ func (k *RemoteKeg) Doctor(ctx context.Context) ([]DoctorIssue, error) { } func (k *RemoteKeg) RemoveNodes(ctx context.Context, opts RemoveNodesOptions) (RemoveNodesResult, error) { - ids := make([]int, len(opts.NodeIDs)) - for i, id := range opts.NodeIDs { - ids[i] = id.ID + nodes := make([]struct { + ID int `json:"id"` + ExpectedHash string `json:"expected_hash,omitempty"` + }, len(opts.Nodes)) + for i, item := range opts.Nodes { + nodes[i].ID = item.ID.ID + nodes[i].ExpectedHash = item.ExpectedHash } req := struct { - NodeIDs []int `json:"node_ids,omitempty"` - Query string `json:"query,omitempty"` - }{ids, opts.Query} + Nodes []struct { + ID int `json:"id"` + ExpectedHash string `json:"expected_hash,omitempty"` + } `json:"nodes,omitempty"` + Query string `json:"query,omitempty"` + }{nodes, opts.Query} var wire struct { Removed []struct { ID int `json:"id"` Rewritten []int `json:"rewritten"` } `json:"removed"` Failure *struct { - NodeID int `json:"node_id"` - Code string `json:"code"` - Status int `json:"status"` - Message string `json:"message"` + NodeID int `json:"node_id"` + Code string `json:"code"` + Status int `json:"status"` + Message string `json:"message"` + CurrentHash string `json:"current_hash,omitempty"` + CurrentContent []byte `json:"current_content,omitempty"` } `json:"failure,omitempty"` } err := k.postJSON(ctx, "/nodes/remove", "RemoveNodes", req, &wire, http.StatusOK) @@ -155,7 +157,7 @@ func (k *RemoteKeg) RemoveNodes(ctx context.Context, opts RemoveNodesOptions) (R out.Removed = append(out.Removed, RemovedNode{ID: NodeId{ID: item.ID}, Rewritten: rewritten}) } if wire.Failure != nil { - out.Failure = &BatchFailure{NodeID: NodeId{ID: wire.Failure.NodeID}, Code: wire.Failure.Code, Status: wire.Failure.Status, Message: wire.Failure.Message} + out.Failure = &BatchFailure{NodeID: NodeId{ID: wire.Failure.NodeID}, Code: wire.Failure.Code, Status: wire.Failure.Status, Message: wire.Failure.Message, CurrentHash: wire.Failure.CurrentHash, CurrentContent: wire.Failure.CurrentContent} } return out, err } @@ -225,7 +227,7 @@ func (k *RemoteKeg) CreateNodes(ctx context.Context, nodes []NodeCreate) ([]Crea Hash string `json:"hash"` Validation *SchemaValidationResult `json:"validation,omitempty"` } - if err := k.postJSON(ctx, "/nodes/batch", "CreateNodes", struct { + if err := k.postJSON(ctx, "/nodes", "CreateNodes", struct { Nodes []wireNode `json:"nodes"` }{wire}, &response, http.StatusCreated, http.StatusOK); err != nil { return nil, err @@ -264,7 +266,7 @@ func (k *RemoteKeg) UpdateNodes(ctx context.Context, updates []NodeUpdateOptions Hash string `json:"hash"` Validation *SchemaValidationResult `json:"validation,omitempty"` } - if err := k.putJSON(ctx, "/nodes/batch", "UpdateNodes", struct { + if err := k.putJSON(ctx, "/nodes", "UpdateNodes", struct { Updates []wireUpdate `json:"updates"` }{wire}, &response, http.StatusOK); err != nil { return nil, err @@ -286,7 +288,7 @@ func (k *RemoteKeg) AppendSnapshots(ctx context.Context, nodes []NodeSnapshotReq wire[i] = wireNode{item.ID.ID, item.Message} } var response []remoteSnapshotEntry - if err := k.postJSON(ctx, "/nodes/snapshots/batch", "AppendSnapshots", struct { + if err := k.postJSON(ctx, "/nodes/snapshots", "AppendSnapshots", struct { Nodes []wireNode `json:"nodes"` }{wire}, &response, http.StatusCreated, http.StatusOK); err != nil { return nil, err @@ -302,40 +304,6 @@ func (k *RemoteKeg) AppendSnapshots(ctx context.Context, nodes []NodeSnapshotReq return out, nil } -func (k *RemoteKeg) ReplaceNodesWithRedirects(ctx context.Context, redirects []NodeRedirect) (ReplaceNodesWithRedirectsResult, error) { - type item struct { - ID int `json:"id"` - Target string `json:"target"` - Title string `json:"title,omitempty"` - TargetID int `json:"target_id"` - ExpectedHash string `json:"expected_hash,omitempty"` - } - wire := make([]item, len(redirects)) - for i, r := range redirects { - wire[i] = item{r.ID.ID, r.Target, r.Title, r.TargetID.ID, r.ExpectedHash} - } - var response struct { - Replaced []int `json:"replaced"` - Failure *struct { - NodeID int `json:"node_id"` - Code string `json:"code"` - Status int `json:"status"` - Message string `json:"message"` - } `json:"failure,omitempty"` - } - err := k.postJSON(ctx, "/nodes/redirects", "ReplaceNodesWithRedirects", struct { - Redirects []item `json:"redirects"` - }{wire}, &response, http.StatusOK) - result := ReplaceNodesWithRedirectsResult{Replaced: make([]NodeId, len(response.Replaced))} - for i, id := range response.Replaced { - result.Replaced[i] = NodeId{ID: id} - } - if response.Failure != nil { - result.Failure = &BatchFailure{NodeID: NodeId{ID: response.Failure.NodeID}, Code: response.Failure.Code, Status: response.Failure.Status, Message: response.Failure.Message} - } - return result, err -} - func (k *RemoteKeg) DexArtifacts(ctx context.Context) (*DexArtifacts, error) { var wire struct { Indexes map[string]string `json:"indexes"` diff --git a/pkg/keg/keg_remote_events.go b/pkg/keg/keg_remote_events.go index 45c85847..9df3d864 100644 --- a/pkg/keg/keg_remote_events.go +++ b/pkg/keg/keg_remote_events.go @@ -2,6 +2,7 @@ package keg import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -51,16 +52,20 @@ func (k *RemoteKeg) watchNodeEvents(ctx context.Context, id NodeId, out chan<- N for ctx.Err() == nil { conn, resp, err := websocket.Dial(ctx, k.eventsURL(id), &websocket.DialOptions{ HTTPClient: k.httpClient(), - HTTPHeader: k.eventsHeader(), + HTTPHeader: k.eventsHeader(ctx), }) if err != nil { - if resp != nil && isPermanentWatchStatus(resp.StatusCode) { + watchErr := err + if resp != nil { + watchErr = k.mapError(resp, "watch node events") + } + if isPermanentWatchError(watchErr) { k.logDebug("remote live watch terminated", - "url", k.eventsURL(id), "status", resp.StatusCode) + "url", k.eventsURL(id), "error", watchErr) return } k.logDebug("remote live watch dial failed; retrying", - "url", k.eventsURL(id), "error", err, "backoff", backoff) + "url", k.eventsURL(id), "error", watchErr, "backoff", backoff) if sleepContext(ctx, backoff) { return } @@ -116,21 +121,22 @@ func (k *RemoteKeg) eventsURL(id NodeId) string { return u.String() } -func (k *RemoteKeg) eventsHeader() http.Header { +func (k *RemoteKeg) eventsHeader(ctx context.Context) http.Header { h := make(http.Header) if token := k.currentToken(); token != "" { h.Set("Authorization", "Bearer "+token) } + if orientation, ok := OrientationHeaderValue(ctx); ok { + h.Set(OrientationHeaderName, orientation) + } return h } -func isPermanentWatchStatus(status int) bool { - switch status { - case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: - return true - default: - return false - } +func isPermanentWatchError(err error) bool { + return errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) || + errors.Is(err, ErrNotExist) || errors.Is(err, ErrOrientationStale) || + errors.Is(err, ErrOrientationDenied) || errors.Is(err, ErrOrientationUnavailable) || + errors.Is(err, ErrOrientationRootUnavailable) } func sleepContext(ctx context.Context, d time.Duration) bool { diff --git a/pkg/keg/keg_remote_events_test.go b/pkg/keg/keg_remote_events_test.go new file mode 100644 index 00000000..2af4bbe8 --- /dev/null +++ b/pkg/keg/keg_remote_events_test.go @@ -0,0 +1,69 @@ +package keg_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/jlrickert/tapper/pkg/keg" +) + +func TestRemoteKegWatchHandshakeUsesStructuredErrorCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code string + wantRetry bool + }{ + {name: "orientation stale stops", code: keg.RemoteCodeOrientationStale}, + {name: "ordinary conflict retries", code: keg.RemoteCodeConflict, wantRetry: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = fmt.Fprintf(w, `{"error":"handshake refused","code":%q}`, tc.code) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + remote := keg.NewRemoteKeg(srv.URL+"/api/v1/@team/kegs/notes", "token", nil) + events, err := remote.Watch(ctx, keg.NodeId{ID: 7}) + if err != nil { + t.Fatalf("Watch: %v", err) + } + + if tc.wantRetry { + deadline := time.Now().Add(1500 * time.Millisecond) + for requests.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if requests.Load() < 2 { + t.Fatalf("requests = %d, want retry", requests.Load()) + } + cancel() + } + + select { + case _, ok := <-events: + if ok { + t.Fatal("unexpected watch event") + } + case <-time.After(time.Second): + t.Fatal("watch did not terminate") + } + if !tc.wantRetry && requests.Load() != 1 { + t.Fatalf("requests = %d, want exactly one", requests.Load()) + } + }) + } +} diff --git a/pkg/keg/keg_remote_test.go b/pkg/keg/keg_remote_test.go index ec116b4d..9003e9b2 100644 --- a/pkg/keg/keg_remote_test.go +++ b/pkg/keg/keg_remote_test.go @@ -79,7 +79,7 @@ func snapshotWire(item kegpkg.Snapshot) map[string]any { func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { t.Helper() - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) backing := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, backing, f.Context()) _, err := backing.Create(f.Context(), &kegpkg.CreateOptions{ @@ -120,38 +120,6 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { h.writeJSON(w, http.StatusOK, map[string]int{"id": id.ID}) }) mux.HandleFunc("POST /nodes", func(w http.ResponseWriter, r *http.Request) { - var req struct { - Content *string `json:"content"` - Meta *string `json:"meta"` - Schema string `json:"schema"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Content == nil { - h.writeError(w, http.StatusBadRequest, "invalid JSON body", "BAD_REQUEST") - return - } - var meta *kegpkg.NodeMeta - if req.Meta != nil && strings.TrimSpace(*req.Meta) != "" { - parsed, err := kegpkg.ParseMeta(r.Context(), []byte(*req.Meta)) - if err != nil { - h.writeError(w, http.StatusBadRequest, "invalid meta: "+err.Error(), "BAD_REQUEST") - return - } - meta = parsed - } - id, err := backing.Create(r.Context(), &kegpkg.CreateOptions{Schema: req.Schema, Body: []byte(*req.Content)}) - if err != nil { - h.kegError(w, err) - return - } - if meta != nil { - if err := backing.SetMeta(r.Context(), id.ID, meta); err != nil { - h.kegError(w, err) - return - } - } - h.writeJSON(w, http.StatusCreated, map[string]int{"id": id.ID.ID}) - }) - mux.HandleFunc("POST /nodes/batch", func(w http.ResponseWriter, r *http.Request) { var req struct { Nodes []struct { Key string `json:"key"` @@ -182,7 +150,7 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { } h.writeJSON(w, http.StatusCreated, wire) }) - mux.HandleFunc("PUT /nodes/batch", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("PUT /nodes", func(w http.ResponseWriter, r *http.Request) { var req struct { Updates []struct { NodeID int `json:"node_id"` @@ -219,7 +187,7 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { } h.writeJSON(w, http.StatusOK, wire) }) - mux.HandleFunc("POST /nodes/snapshots/batch", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /nodes/snapshots", func(w http.ResponseWriter, r *http.Request) { var req struct { Nodes []struct { NodeID int `json:"node_id"` @@ -303,17 +271,40 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { } h.writeJSON(w, http.StatusOK, resp) }) - mux.HandleFunc("DELETE /nodes/{id}", func(w http.ResponseWriter, r *http.Request) { - id, ok := h.parseID(w, r) - if !ok { + mux.HandleFunc("POST /nodes/remove", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Nodes []struct { + ID int `json:"id"` + ExpectedHash string `json:"expected_hash"` + } `json:"nodes"` + Query string `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid JSON body", "BAD_REQUEST") return } - rewritten, err := backing.Remove(r.Context(), id) + nodes := make([]kegpkg.NodeRemoveOptions, len(req.Nodes)) + for i, item := range req.Nodes { + nodes[i] = kegpkg.NodeRemoveOptions{ID: kegpkg.NodeId{ID: item.ID}, ExpectedHash: item.ExpectedHash} + } + result, err := backing.RemoveNodes(r.Context(), kegpkg.RemoveNodesOptions{Nodes: nodes, Query: req.Query}) if err != nil { h.kegError(w, err) return } - h.writeJSON(w, http.StatusOK, map[string][]string{"rewritten": rewrittenWire(rewritten)}) + removed := make([]map[string]any, len(result.Removed)) + for i, item := range result.Removed { + rewritten := make([]int, len(item.Rewritten)) + for j, id := range item.Rewritten { + rewritten[j] = id.ID + } + removed[i] = map[string]any{"id": item.ID.ID, "rewritten": rewritten} + } + response := map[string]any{"removed": removed} + if result.Failure != nil { + response["failure"] = map[string]any{"node_id": result.Failure.NodeID.ID, "code": result.Failure.Code, "status": result.Failure.Status, "message": result.Failure.Message, "current_hash": result.Failure.CurrentHash, "current_content": result.Failure.CurrentContent} + } + h.writeJSON(w, http.StatusOK, response) }) mux.HandleFunc("POST /nodes/{id}/move", func(w http.ResponseWriter, r *http.Request) { id, ok := h.parseID(w, r) @@ -321,13 +312,14 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { return } var req struct { - Dst int `json:"dst"` + Dst int `json:"dst"` + ExpectedHash string `json:"expected_hash"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid JSON body", "BAD_REQUEST") return } - rewritten, err := backing.Move(r.Context(), id, kegpkg.NodeId{ID: req.Dst}) + rewritten, err := backing.Move(r.Context(), kegpkg.NodeMoveOptions{Source: id, Destination: kegpkg.NodeId{ID: req.Dst}, ExpectedHash: req.ExpectedHash}) if err != nil { h.kegError(w, err) return @@ -411,7 +403,7 @@ func newMockOpsHub(t *testing.T, f *sandbox.Sandbox, token string) *mockOpsHub { } w.Write(data) }) - mux.HandleFunc("POST /index/rebuild", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /indexes/rebuild", func(w http.ResponseWriter, r *http.Request) { var req struct { NoUpdate bool `json:"no_update"` } @@ -653,7 +645,8 @@ func TestRemoteKegRoundTripBasics(t *testing.T) { require.NotNil(t, view.Stats) // SetContent / GetContent round-trip raw bytes. - require.NoError(t, rk.SetContent(ctx, id.ID, []byte("# Gamma node\n\nupdated body\n"))) + updated, err := rk.UpdateNode(ctx, kegpkg.NodeUpdateOptions{ID: id.ID, Content: []byte("# Gamma node\n\nupdated body\n"), ExpectedHash: view.Hash()}) + require.NoError(t, err) content, err := rk.GetContent(ctx, id.ID) require.NoError(t, err) require.Contains(t, string(content), "updated body") @@ -663,7 +656,8 @@ func TestRemoteKegRoundTripBasics(t *testing.T) { require.NoError(t, err) require.Contains(t, meta.Tags(), "gamma") meta.SetTags([]string{"gamma", "json-transport"}) - require.NoError(t, rk.SetMeta(ctx, id.ID, meta)) + _, err = rk.UpdateNodes(ctx, []kegpkg.NodeUpdateOptions{{ID: id.ID, Meta: []byte(meta.ToYAML()), HasMeta: true, ExpectedHash: updated.Hash}}) + require.NoError(t, err) meta, err = rk.GetMeta(ctx, id.ID) require.NoError(t, err) require.Contains(t, meta.Tags(), "json-transport") @@ -732,9 +726,8 @@ func TestRemoteMutationBatchesPreserveOrderAndAtomicity(t *testing.T) { forwardType, ok := forwardMeta.Get("type") require.True(t, ok) require.Equal(t, "note", forwardType) - _, err = remote.UpdateNodes(ctx, []kegpkg.NodeUpdateOptions{ - {ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true}, + {ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: forwardBefore.Hash()}, {ID: created[1].ID, Schema: "task", Content: []byte("# Never\n"), HasContent: true, ExpectedHash: "stale"}, }) require.ErrorIs(t, err, kegpkg.ErrConflict) @@ -750,7 +743,7 @@ func TestRemoteMutationBatchesPreserveOrderAndAtomicity(t *testing.T) { require.Equal(t, "note", forwardType, "failed remote batch changed the stored schema") _, err = remote.UpdateNodes(ctx, []kegpkg.NodeUpdateOptions{{ - ID: created[0].ID, Schema: "task", Content: []byte("# Reclassified\n"), HasContent: true, + ID: created[0].ID, Schema: "task", Content: []byte("# Reclassified\n"), HasContent: true, ExpectedHash: forwardAfter.Hash(), }}) require.NoError(t, err) forwardMeta, err = remote.GetMeta(ctx, created[0].ID) @@ -772,12 +765,16 @@ func TestRemoteKegMoveRemoveRewritten(t *testing.T) { ctx := f.Context() // Node 1 links to ../2; moving 2 rewrites node 1. - rewritten, err := rk.Move(ctx, kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 5}) + view, err := rk.ReadNode(ctx, kegpkg.NodeId{ID: 2}) + require.NoError(t, err) + rewritten, err := rk.Move(ctx, kegpkg.NodeMoveOptions{Source: kegpkg.NodeId{ID: 2}, Destination: kegpkg.NodeId{ID: 5}, ExpectedHash: view.Hash()}) require.NoError(t, err) require.Contains(t, rewritten, kegpkg.NodeId{ID: 1}) // Removing the moved node rewrites node 1 again (link drop). - rewritten, err = rk.Remove(ctx, kegpkg.NodeId{ID: 5}) + view, err = rk.ReadNode(ctx, kegpkg.NodeId{ID: 5}) + require.NoError(t, err) + rewritten, err = rk.Remove(ctx, kegpkg.NodeRemoveOptions{ID: kegpkg.NodeId{ID: 5}, ExpectedHash: view.Hash()}) require.NoError(t, err) require.Contains(t, rewritten, kegpkg.NodeId{ID: 1}) } @@ -813,7 +810,7 @@ func TestRemoteKegExportImport(t *testing.T) { defer rc.Close() // Land the archive in a second, freshly initialized LocalKeg. - dst := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + dst := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, dst, ctx) imported, err := dst.ImportNodes(ctx, rc, kegpkg.ImportNodesOptions{}) require.NoError(t, err) @@ -831,7 +828,7 @@ func TestRemoteKegImportRoundTrip(t *testing.T) { // Export a node from a local keg and import it into the remote keg // with fresh ids. - src := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + src := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, src, ctx) srcID, err := src.Create(ctx, &kegpkg.CreateOptions{ Title: "Imported node", @@ -859,6 +856,8 @@ func TestRemoteKegSingleRoundTrip(t *testing.T) { f, hub, rk := newRemoteKegFixture(t) ctx := f.Context() id := kegpkg.NodeId{ID: 1} + view, err := rk.ReadNode(ctx, id) + require.NoError(t, err) cases := []struct { name string @@ -868,8 +867,9 @@ func TestRemoteKegSingleRoundTrip(t *testing.T) { _, err := rk.ReadNode(ctx, id) return err }}, - {"SetContent", func() error { - return rk.SetContent(ctx, id, []byte("# Alpha node\n\nrewritten\n")) + {"UpdateNode", func() error { + _, err := rk.UpdateNode(ctx, kegpkg.NodeUpdateOptions{ID: id, Content: []byte("# Alpha node\n\nrewritten\n"), ExpectedHash: view.Hash()}) + return err }}, {"Query", func() error { _, err := rk.Query(ctx, kegpkg.QueryOptions{Expr: "shared"}) diff --git a/pkg/keg/keg_config.go b/pkg/keg/keg_settings.go similarity index 69% rename from pkg/keg/keg_config.go rename to pkg/keg/keg_settings.go index 1ead97e8..d1dbc6c4 100644 --- a/pkg/keg/keg_config.go +++ b/pkg/keg/keg_settings.go @@ -1,10 +1,5 @@ package keg -// Package config provides versioned configuration management for the KEG -// application. It supports loading, parsing, converting, and accessing -// configuration data with environment variable expansion and version -// migration. - import ( "encoding/json" "errors" @@ -12,17 +7,20 @@ import ( "strings" "time" + "github.com/jlrickert/tapper/pkg/schemas" "gopkg.in/yaml.v3" ) -const ( - KegConfigSchemaURL = "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/keg-config.json" - kegConfigSchemaModeline = "# yaml-language-server: $schema=" + KegConfigSchemaURL + "\n" -) +// KegSettingsSchemaURL is the published JSON Schema for keg settings YAML. +// It is the $id of the schema and the fallback modeline target; the modeline +// itself is added only when keg settings are opened in an editor (see +// Tap.KegSettingsEdit), never by the serializers below — keg settings are +// persisted, and on a hub they are shared, so a modeline naming one machine's +// filesystem has no business in the stored document. +const KegSettingsSchemaURL = schemas.KegSettingsURL -// ConfigV1 KegConfigV1 represents the initial version of the KEG configuration -// specification. -type ConfigV1 struct { +// SettingsV1 represents the initial version of the KEG settings specification. +type SettingsV1 struct { // Kegv is the version of the specification. Kegv string `yaml:"kegv"` @@ -50,9 +48,9 @@ type ConfigV1 struct { path string } -// ConfigV2 KegConfigV2 represents the second (current) version of the KEG configuration +// SettingsV2 represents the second (current) version of the KEG settings // specification. It extends V1 with additional fields such as Links. -type ConfigV2 struct { +type SettingsV2 struct { // Kegv is the version of the specification. Kegv string `yaml:"kegv" json:"kegv"` @@ -98,13 +96,15 @@ type ConfigV2 struct { Timezone string `yaml:"timezone,omitempty" json:"timezone,omitempty"` // Snapshots controls automatic snapshot behavior for this keg. - Snapshots *SnapshotConfig `yaml:"snapshots,omitempty" json:"snapshots,omitempty"` + Snapshots *SnapshotSettings `yaml:"snapshots,omitempty" json:"snapshots,omitempty"` // SchemaPolicy controls actor validation modes. Strict adds explicit schema // selection to live nonzero-node writes whose resolved mode is block. SchemaPolicy *SchemaPolicy `yaml:"schemaPolicy,omitempty" json:"schemaPolicy,omitempty"` path string + raw []byte + hash string } const ( @@ -114,8 +114,8 @@ const ( DefaultSnapshotIdleAfter = time.Hour ) -// SnapshotConfig holds per-keg automatic snapshot policy settings. -type SnapshotConfig struct { +// SnapshotSettings holds per-keg automatic snapshot policy settings. +type SnapshotSettings struct { // Mode controls whether the hub should create idle snapshots automatically. // Supported values are "auto" and "off". Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` @@ -125,26 +125,27 @@ type SnapshotConfig struct { IdleAfter string `yaml:"idleAfter,omitempty" json:"idleAfter,omitempty"` } -func DefaultSnapshotConfig() *SnapshotConfig { - return &SnapshotConfig{ +// DefaultSnapshotSettings returns the default automatic snapshot settings. +func DefaultSnapshotSettings() *SnapshotSettings { + return &SnapshotSettings{ Mode: SnapshotModeAuto, IdleAfter: formatSnapshotDuration(DefaultSnapshotIdleAfter), } } -// SnapshotPolicy resolves the effective snapshot policy for this config. -func (kc *Config) SnapshotPolicy() (mode string, idleAfter time.Duration, err error) { +// SnapshotPolicy resolves the effective snapshot policy for this settings. +func (kc *Settings) SnapshotPolicy() (mode string, idleAfter time.Duration, err error) { if kc == nil { return SnapshotModeAuto, DefaultSnapshotIdleAfter, nil } cfg := kc.Snapshots if cfg == nil { - cfg = DefaultSnapshotConfig() + cfg = DefaultSnapshotSettings() } return cfg.policy() } -func (sc *SnapshotConfig) policy() (string, time.Duration, error) { +func (sc *SnapshotSettings) policy() (string, time.Duration, error) { mode := SnapshotModeAuto idleAfter := formatSnapshotDuration(DefaultSnapshotIdleAfter) if sc != nil { @@ -181,13 +182,13 @@ func formatSnapshotDuration(d time.Duration) string { return d.String() } -// LinkEntry represents a named link in the KEG configuration. +// 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 } -// IndexEntry represents an entry in the indexes list in the KEG configuration. +// 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" // or "concepts.md". The on-disk path is always under the keg's dex/ directory @@ -202,14 +203,13 @@ type IndexEntry struct { Sort string `yaml:"sort,omitempty" json:"sort,omitempty"` // sort order for query-filtered indexes: "updated" (default), "id", "created", "accessed" } -// Config KegConfig is an alias for the latest configuration version. Update this alias -// when introducing a newer configuration version. -type Config = ConfigV2 +// Settings is the latest version of the keg settings document. +type Settings = SettingsV2 -// toV2 converts a ConfigV1 value to the ConfigV2 representation. -func (c *ConfigV1) toV2() *ConfigV2 { - return &ConfigV2{ - Kegv: ConfigV2VersionString, +// toV2 converts a SettingsV1 value to the SettingsV2 representation. +func (c *SettingsV1) toV2() *SettingsV2 { + return &SettingsV2{ + Kegv: SettingsV2VersionString, Updated: c.Updated, Title: c.Title, URL: c.URL, @@ -218,16 +218,16 @@ func (c *ConfigV1) toV2() *ConfigV2 { Summary: c.Summary, Links: nil, // No links in v1, so leave as nil Indexes: c.Indexes, - Snapshots: DefaultSnapshotConfig(), + Snapshots: DefaultSnapshotSettings(), path: "", } } -type ConfigOption = func(cfg *Config) +type SettingsOption = func(cfg *Settings) -func NewConfig(options ...ConfigOption) *Config { - cfg := &Config{ - Kegv: ConfigV2VersionString, +func NewSettings(options ...SettingsOption) *Settings { + cfg := &Settings{ + Kegv: SettingsV2VersionString, Updated: "2025-08-19 12:54:28Z", Title: "My KEG", URL: "git@github.com:YOU/keg.git", @@ -245,7 +245,7 @@ func NewConfig(options ...ConfigOption) *Config { - Indices under dex/ are generated automatically by keg tooling. - Use tags in node meta.yaml to organize and filter content.`, Timezone: "UTC", - Snapshots: DefaultSnapshotConfig(), + Snapshots: DefaultSnapshotSettings(), SchemaPolicy: &SchemaPolicy{Strict: true}, Indexes: SystemIndexEntries(), } @@ -256,42 +256,50 @@ func NewConfig(options ...ConfigOption) *Config { return cfg } -// ParseKegConfig parses raw YAML config data into the latest Config version. +// ParseKegSettings parses raw YAML settings data into the latest Settings version. // It detects the "kegv" version field and performs migration from earlier // versions when necessary. -func ParseKegConfig(data []byte) (*Config, error) { - return parseKegConfig(data, false) +func ParseKegSettings(data []byte) (*Settings, error) { + cfg, err := parseKegSettings(data, false) + if cfg != nil { + cfg.raw = append([]byte(nil), data...) + } + return cfg, err } -// ParseKegConfigStrict parses raw user-supplied config data for persistence. +// ParseKegSettingsStrict parses raw user-supplied settings data for persistence. // It rejects user-defined index entries that collide with required system // indexes or duplicate another user index. -func ParseKegConfigStrict(data []byte) (*Config, error) { - return parseKegConfig(data, true) +func ParseKegSettingsStrict(data []byte) (*Settings, error) { + cfg, err := parseKegSettings(data, true) + if cfg != nil { + cfg.raw = append([]byte(nil), data...) + } + return cfg, err } -func parseKegConfig(data []byte, strict bool) (*Config, error) { - var configV2 ConfigV2 +func parseKegSettings(data []byte, strict bool) (*Settings, error) { + var settingsV2 SettingsV2 // Detect version by unmarshaling into a generic map var raw map[string]any if err := yaml.Unmarshal(data, &raw); err != nil { - return &configV2, fmt.Errorf("failed to parse keg data: %w", errors.Join(ErrParse, err)) + return &settingsV2, fmt.Errorf("failed to parse keg data: %w", errors.Join(ErrParse, err)) } // Check for "kegv" version field version, ok := raw["kegv"].(string) if !ok { - return &configV2, fmt.Errorf("missing or invalid kegv version field") + return &settingsV2, fmt.Errorf("missing or invalid kegv version field") } switch version { - case ConfigV1VersionString: - var configV1 ConfigV1 - if err := yaml.Unmarshal(data, &configV1); err != nil { - return &configV2, err + case SettingsV1VersionString: + var settingsV1 SettingsV1 + if err := yaml.Unmarshal(data, &settingsV1); err != nil { + return &settingsV2, err } - cfg := configV1.toV2() + cfg := settingsV1.toV2() cfg.applyDefaults() if err := cfg.validateSnapshots(); err != nil { return cfg, err @@ -303,34 +311,34 @@ func parseKegConfig(data []byte, strict bool) (*Config, error) { return cfg, err } return cfg, nil - case ConfigV2VersionString: - if err := yaml.Unmarshal(data, &configV2); err != nil { - return &configV2, err + case SettingsV2VersionString: + if err := yaml.Unmarshal(data, &settingsV2); err != nil { + return &settingsV2, err } default: - return &configV2, fmt.Errorf("unsupported config version: %s", version) + return &settingsV2, fmt.Errorf("unsupported settings version: %s", version) } - configV2.applyDefaults() - if err := configV2.validateSnapshots(); err != nil { - return &configV2, err + settingsV2.applyDefaults() + if err := settingsV2.validateSnapshots(); err != nil { + return &settingsV2, err } - if err := configV2.validateListFields(); err != nil { - return &configV2, err + if err := settingsV2.validateListFields(); err != nil { + return &settingsV2, err } - if err := configV2.normalizeIndexes(strict); err != nil { - return &configV2, err + if err := settingsV2.normalizeIndexes(strict); err != nil { + return &settingsV2, err } - return &configV2, nil + return &settingsV2, nil } // applyDefaults fills in zero-value fields with their documented defaults. -func (kc *ConfigV2) applyDefaults() { +func (kc *SettingsV2) applyDefaults() { if kc.Timezone == "" { kc.Timezone = "UTC" } if kc.Snapshots == nil { - kc.Snapshots = DefaultSnapshotConfig() + kc.Snapshots = DefaultSnapshotSettings() return } if strings.TrimSpace(kc.Snapshots.Mode) == "" { @@ -345,7 +353,7 @@ func (kc *ConfigV2) applyDefaults() { } } -func (kc *ConfigV2) validateSnapshots() error { +func (kc *SettingsV2) validateSnapshots() error { if kc == nil { return nil } @@ -353,10 +361,10 @@ func (kc *ConfigV2) validateSnapshots() error { return err } -// validateListFields rejects an unusable selector when the config is parsed +// validateListFields rejects an unusable selector when the settings is parsed // rather than when a listing is rendered, so a typo surfaces at the point of // editing instead of silently blanking a column later. -func (kc *ConfigV2) validateListFields() error { +func (kc *SettingsV2) validateListFields() error { if kc == nil || len(kc.ListFields) == 0 { return nil } @@ -366,7 +374,7 @@ func (kc *ConfigV2) validateListFields() error { return nil } -func (kc *ConfigV2) normalizeIndexes(strict bool) error { +func (kc *SettingsV2) normalizeIndexes(strict bool) error { if kc == nil { return nil } @@ -378,20 +386,20 @@ func (kc *ConfigV2) normalizeIndexes(strict bool) error { return nil } -func (kc *ConfigV2) materializeSystemIndexes() { +func (kc *SettingsV2) materializeSystemIndexes() { _ = kc.normalizeIndexes(false) } // MaterializeSystemIndexes ensures required system indexes are present in the -// runtime config view and removes any legacy persisted declarations of those +// runtime settings view and removes any legacy persisted declarations of those // indexes. -func (kc *ConfigV2) MaterializeSystemIndexes() { +func (kc *SettingsV2) MaterializeSystemIndexes() { kc.materializeSystemIndexes() } -func (kc *ConfigV2) persistedCopy() (*ConfigV2, error) { +func (kc *SettingsV2) persistedCopy() (*SettingsV2, error) { if kc == nil { - return nil, fmt.Errorf("config is nil") + return nil, fmt.Errorf("settings is nil") } out := *kc out.applyDefaults() @@ -451,8 +459,8 @@ func IsSystemIndex(name string) bool { } } -// UserIndexEntries returns the user-defined indexes from a runtime config. -func (kc *Config) UserIndexEntries() []IndexEntry { +// UserIndexEntries returns the user-defined indexes from a runtime settings. +func (kc *Settings) UserIndexEntries() []IndexEntry { if kc == nil { return nil } @@ -462,7 +470,7 @@ func (kc *Config) UserIndexEntries() []IndexEntry { // Location returns the *time.Location for the configured Timezone. // It returns time.UTC if the Timezone field is empty or invalid. -func (kc *Config) Location() *time.Location { +func (kc *Settings) Location() *time.Location { tz := kc.Timezone if tz == "" { return time.UTC @@ -474,7 +482,7 @@ func (kc *Config) Location() *time.Location { return loc } -func (kc *Config) ResolveAlias(alias string) (*Target, error) { +func (kc *Settings) ResolveAlias(alias string) (*Target, error) { for _, entry := range kc.Links { if alias == entry.Alias { kt, err := Parse(entry.URL) @@ -487,21 +495,45 @@ func (kc *Config) ResolveAlias(alias string) (*Target, error) { return nil, fmt.Errorf("alias %s not found: %w", alias, ErrNotExist) } -// ToYAML serializes the Config to YAML. -func (kc *Config) ToYAML() ([]byte, error) { +// ToYAML serializes the Settings to YAML. The result is what gets persisted — +// to the on-disk `keg` file, or over the wire to a hub — so it carries no +// schema modeline. Editors get one added on open; see Tap.KegSettingsEdit. +func (kc *Settings) ToYAML() ([]byte, error) { persisted, err := kc.persistedCopy() if err != nil { return nil, err } - body, err := yaml.Marshal(persisted) - if err != nil { - return nil, err + return yaml.Marshal(persisted) +} + +// Raw returns the exact document representation read from storage. Settings +// constructed in memory fall back to their canonical YAML representation. +func (kc *Settings) Raw() []byte { + if kc == nil { + return nil + } + if kc.raw != nil { + return append([]byte(nil), kc.raw...) } - return append([]byte(kegConfigSchemaModeline), body...), nil + out, _ := kc.ToYAML() + return out +} + +// Hash returns the optimistic-concurrency token associated with Raw. +func (kc *Settings) Hash() string { + if kc == nil { + return "" + } + return kc.hash +} + +func (kc *Settings) setDocument(raw []byte, hash string) { + kc.raw = append([]byte(nil), raw...) + kc.hash = hash } -// ToJSON serializes the Config to JSON. -func (kc *Config) ToJSON() ([]byte, error) { +// ToJSON serializes the Settings to JSON. +func (kc *Settings) ToJSON() ([]byte, error) { persisted, err := kc.persistedCopy() if err != nil { return nil, err @@ -509,11 +541,11 @@ func (kc *Config) ToJSON() ([]byte, error) { return json.Marshal(persisted) } -func (kc *Config) String() string { +func (kc *Settings) String() string { out, _ := kc.ToYAML() return string(out) } -func (kc *Config) Touch(t time.Time) { +func (kc *Settings) Touch(t time.Time) { kc.Updated = t.Format(time.RFC3339) } diff --git a/pkg/keg/keg_config_test.go b/pkg/keg/keg_settings_test.go similarity index 69% rename from pkg/keg/keg_config_test.go rename to pkg/keg/keg_settings_test.go index b7070c41..f83091e6 100644 --- a/pkg/keg/keg_config_test.go +++ b/pkg/keg/keg_settings_test.go @@ -19,7 +19,7 @@ title: "Test KEG V1" url: "https://example.com" creator: "Jared Rickert" state: "living" -summary: "This is a test KEG V1 config" +summary: "This is a test KEG V1 settings" indexes: - file: "index1.md" summary: "Index 1 summary" @@ -27,16 +27,16 @@ indexes: summary: "Index 2 summary" ` - config, err := keg.ParseKegConfig([]byte(v1Yaml)) - require.NoError(t, err, "ParseKegConfig failed") + settings, err := keg.ParseKegSettings([]byte(v1Yaml)) + require.NoError(t, err, "ParseKegSettings failed") - require.Equal(t, keg.ConfigV2VersionString, config.Kegv) - require.Equal(t, "Test KEG V1", config.Title) - userIndexes := config.UserIndexEntries() + require.Equal(t, keg.SettingsV2VersionString, settings.Kegv) + require.Equal(t, "Test KEG V1", settings.Title) + userIndexes := settings.UserIndexEntries() require.Len(t, userIndexes, 2) require.Equal(t, "index1.md", userIndexes[0].File) require.Equal(t, "index2.md", userIndexes[1].File) - require.Empty(t, config.Links) + require.Empty(t, settings.Links) } func TestParseConfigDataV2(t *testing.T) { @@ -47,7 +47,7 @@ title: "Test KEG V2" url: "https://example.com/v2" creator: "creator-v2" state: "archived" -summary: "This is a test KEG V2 config" +summary: "This is a test KEG V2 settings" instructions: "Use this KEG for parser tests." links: - alias: "home" @@ -66,16 +66,16 @@ schemaPolicy: restore: block ` - config, err := keg.ParseKegConfig([]byte(v2Yaml)) - require.NoError(t, err, "ParseKegConfig failed") + settings, err := keg.ParseKegSettings([]byte(v2Yaml)) + require.NoError(t, err, "ParseKegSettings failed") - require.Equal(t, keg.ConfigV2VersionString, config.Kegv) - require.Equal(t, "Test KEG V2", config.Title) - require.Equal(t, "Use this KEG for parser tests.", config.Instructions) + require.Equal(t, keg.SettingsV2VersionString, settings.Kegv) + require.Equal(t, "Test KEG V2", settings.Title) + require.Equal(t, "Use this KEG for parser tests.", settings.Instructions) - require.Len(t, config.Links, 2, "expected 2 links") + require.Len(t, settings.Links, 2, "expected 2 links") links := map[string]string{} - for _, l := range config.Links { + for _, l := range settings.Links { links[l.Alias] = l.URL } require.Contains(t, links, "home") @@ -83,14 +83,14 @@ schemaPolicy: require.Equal(t, "https://keg.example.com/@user/home", links["home"]) require.Equal(t, "https://keg.example.com/@user/docs", links["docs"]) - userIndexes := config.UserIndexEntries() + userIndexes := settings.UserIndexEntries() require.Len(t, userIndexes, 1) require.Equal(t, "index1.md", userIndexes[0].File) - require.Equal(t, keg.ValidationModeOff, config.SchemaPolicy.Human) - require.Equal(t, keg.ValidationModeBlock, config.SchemaPolicy.Agent) - require.Equal(t, keg.ValidationModeWarn, config.SchemaPolicy.API) + require.Equal(t, keg.ValidationModeOff, settings.SchemaPolicy.Human) + require.Equal(t, keg.ValidationModeBlock, settings.SchemaPolicy.Agent) + require.Equal(t, keg.ValidationModeWarn, settings.SchemaPolicy.API) - yamlOut, err := config.ToYAML() + yamlOut, err := settings.ToYAML() require.NoError(t, err) require.NotContains(t, string(yamlOut), " default:") require.NotContains(t, string(yamlOut), " import:") @@ -102,13 +102,13 @@ func TestParseConfigV2_SnapshotPolicyDefaults(t *testing.T) { kegv: "2025-07" title: "No snapshot policy" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - require.NotNil(t, config.Snapshots) - require.Equal(t, keg.SnapshotModeAuto, config.Snapshots.Mode) - require.Equal(t, "1h", config.Snapshots.IdleAfter) + require.NotNil(t, settings.Snapshots) + require.Equal(t, keg.SnapshotModeAuto, settings.Snapshots.Mode) + require.Equal(t, "1h", settings.Snapshots.IdleAfter) - mode, idleAfter, err := config.SnapshotPolicy() + mode, idleAfter, err := settings.SnapshotPolicy() require.NoError(t, err) require.Equal(t, keg.SnapshotModeAuto, mode) require.Equal(t, time.Hour, idleAfter) @@ -121,11 +121,11 @@ title: "Partial snapshot policy" snapshots: mode: off ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - require.NotNil(t, config.Snapshots) - require.Equal(t, keg.SnapshotModeOff, config.Snapshots.Mode) - require.Equal(t, "1h", config.Snapshots.IdleAfter) + require.NotNil(t, settings.Snapshots) + require.Equal(t, keg.SnapshotModeOff, settings.Snapshots.Mode) + require.Equal(t, "1h", settings.Snapshots.IdleAfter) } func TestParseConfigStrict_RejectsInvalidSnapshotPolicy(t *testing.T) { @@ -168,7 +168,7 @@ snapshots: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := keg.ParseKegConfigStrict([]byte(tt.body)) + _, err := keg.ParseKegSettingsStrict([]byte(tt.body)) require.Error(t, err) require.Contains(t, err.Error(), tt.want) }) @@ -176,7 +176,7 @@ snapshots: } func TestConfigSnapshotPolicyYAMLAndJSON(t *testing.T) { - cfg := keg.NewConfig() + cfg := keg.NewSettings() yamlOut, err := cfg.ToYAML() require.NoError(t, err) @@ -194,8 +194,8 @@ func TestConfigSnapshotPolicyYAMLAndJSON(t *testing.T) { require.Equal(t, "1h", snapshots["idleAfter"]) } -func TestKegConfigJSONSchemaIncludesCurrentProperties(t *testing.T) { - raw, err := os.ReadFile("../../schemas/keg-config.json") +func TestKegSettingsJSONSchemaIncludesCurrentProperties(t *testing.T) { + raw, err := os.ReadFile("../../schemas/keg-settings.json") require.NoError(t, err) var schema map[string]any @@ -231,9 +231,9 @@ kegv: "invalid-version" title: "Invalid version test" ` - _, err := keg.ParseKegConfig([]byte(invalidYaml)) - require.Error(t, err, "expected error for unsupported config version") - require.Contains(t, err.Error(), "unsupported config version") + _, err := keg.ParseKegSettings([]byte(invalidYaml)) + require.Error(t, err, "expected error for unsupported settings version") + require.Contains(t, err.Error(), "unsupported settings version") } func TestParseConfigDataMissingVersion(t *testing.T) { @@ -241,13 +241,13 @@ func TestParseConfigDataMissingVersion(t *testing.T) { title: "Missing version test" ` - _, err := keg.ParseKegConfig([]byte(missingVersionYaml)) + _, err := keg.ParseKegSettings([]byte(missingVersionYaml)) require.Error(t, err, "expected error for missing version field") require.Contains(t, err.Error(), "missing or invalid kegv") } func TestLegacyConfigPropertiesAreToleratedAndDropped(t *testing.T) { - cfg, err := keg.ParseKegConfig([]byte(`kegv: "2025-07" + cfg, err := keg.ParseKegSettings([]byte(`kegv: "2025-07" title: Legacy entities: note: {id: 1, summary: Notes} @@ -267,11 +267,15 @@ site: } } -func TestConfigToYAML_PrependsSchemaModeline(t *testing.T) { - cfg := keg.NewConfig() +func TestConfigToYAML_OmitsSchemaModeline(t *testing.T) { + // ToYAML is the persistence serializer: its output is what lands in the + // on-disk `keg` file and what goes over the wire to a hub, where a + // modeline naming one machine's filesystem would be meaningless. The + // modeline is added only when a settings is opened in an editor. + cfg := keg.NewSettings() out, err := cfg.ToYAML() require.NoError(t, err) - require.True(t, strings.HasPrefix(string(out), "# yaml-language-server: $schema="+keg.KegConfigSchemaURL+"\n")) + require.NotContains(t, string(out), "yaml-language-server") } func TestParseConfigV2_IndexQueryField(t *testing.T) { @@ -283,9 +287,9 @@ indexes: summary: "concept nodes" query: "entity=concept" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - userIndexes := config.UserIndexEntries() + userIndexes := settings.UserIndexEntries() require.Len(t, userIndexes, 1) require.Equal(t, "entity=concept", userIndexes[0].Query) } @@ -301,16 +305,16 @@ indexes: summary: "concept nodes" query: "entity=concept" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - userIndexes := config.UserIndexEntries() + userIndexes := settings.UserIndexEntries() require.Len(t, userIndexes, 1) require.Equal(t, "concepts.md", userIndexes[0].File) - require.Contains(t, indexFiles(config.Indexes), "backlinks") + require.Contains(t, indexFiles(settings.Indexes), "backlinks") } func TestNewConfig_SystemIndexesAreRuntimeOnly(t *testing.T) { - cfg := keg.NewConfig() + cfg := keg.NewSettings() require.NotEmpty(t, cfg.Indexes) require.Empty(t, cfg.UserIndexEntries()) for _, entry := range cfg.Indexes { @@ -329,13 +333,13 @@ func TestParseConfigV2_MaterializesSystemIndexes(t *testing.T) { kegv: "2025-07" title: "No indexes" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) require.Equal(t, []string{"nodes.tsv", "changes.md", "tags", "links", "backlinks", "timeline", "dirty"}, - indexFiles(config.Indexes)[:len(keg.SystemIndexEntries())], + indexFiles(settings.Indexes)[:len(keg.SystemIndexEntries())], ) - require.Empty(t, config.UserIndexEntries()) + require.Empty(t, settings.UserIndexEntries()) } func TestParseConfigStrict_RejectsSystemIndex(t *testing.T) { @@ -346,7 +350,7 @@ indexes: - file: "changes.md" summary: "try to override" ` - _, err := keg.ParseKegConfigStrict([]byte(yamlData)) + _, err := keg.ParseKegSettingsStrict([]byte(yamlData)) require.Error(t, err) require.Contains(t, err.Error(), "required system index") } @@ -361,7 +365,7 @@ indexes: - file: "concepts.md" summary: "two" ` - _, err := keg.ParseKegConfigStrict([]byte(yamlData)) + _, err := keg.ParseKegSettingsStrict([]byte(yamlData)) require.Error(t, err) require.Contains(t, err.Error(), "duplicate user index") } @@ -376,11 +380,11 @@ indexes: - file: "concepts.md" summary: "concept nodes" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - require.Equal(t, []string{"concepts.md"}, indexFiles(config.UserIndexEntries())) + require.Equal(t, []string{"concepts.md"}, indexFiles(settings.UserIndexEntries())) - out, err := config.ToYAML() + out, err := settings.ToYAML() require.NoError(t, err) require.NotContains(t, string(out), `file: changes.md`) require.Contains(t, string(out), `file: concepts.md`) @@ -391,9 +395,9 @@ func TestParseConfigV2_TimezoneDefaultsToUTC(t *testing.T) { kegv: "2025-07" title: "No timezone" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - require.Equal(t, "UTC", config.Timezone, "Timezone should default to UTC when omitted") + require.Equal(t, "UTC", settings.Timezone, "Timezone should default to UTC when omitted") } func TestParseConfigV2_TimezoneExplicit(t *testing.T) { @@ -402,31 +406,31 @@ kegv: "2025-07" title: "With timezone" timezone: "America/Chicago" ` - config, err := keg.ParseKegConfig([]byte(yamlData)) + settings, err := keg.ParseKegSettings([]byte(yamlData)) require.NoError(t, err) - require.Equal(t, "America/Chicago", config.Timezone) + require.Equal(t, "America/Chicago", settings.Timezone) } func TestConfigLocation_UTC(t *testing.T) { - cfg := &keg.Config{Kegv: keg.ConfigV2VersionString} + cfg := &keg.Settings{Kegv: keg.SettingsV2VersionString} loc := cfg.Location() require.Equal(t, "UTC", loc.String()) } func TestConfigLocation_ValidTimezone(t *testing.T) { - cfg := &keg.Config{Kegv: keg.ConfigV2VersionString, Timezone: "America/Chicago"} + cfg := &keg.Settings{Kegv: keg.SettingsV2VersionString, Timezone: "America/Chicago"} loc := cfg.Location() require.Equal(t, "America/Chicago", loc.String()) } func TestConfigLocation_InvalidTimezoneDefaultsToUTC(t *testing.T) { - cfg := &keg.Config{Kegv: keg.ConfigV2VersionString, Timezone: "Invalid/Zone"} + cfg := &keg.Settings{Kegv: keg.SettingsV2VersionString, Timezone: "Invalid/Zone"} loc := cfg.Location() require.Equal(t, "UTC", loc.String()) } func TestConfigLocation_EmptyTimezoneDefaultsToUTC(t *testing.T) { - cfg := &keg.Config{Kegv: keg.ConfigV2VersionString, Timezone: ""} + cfg := &keg.Settings{Kegv: keg.SettingsV2VersionString, Timezone: ""} loc := cfg.Location() require.Equal(t, "UTC", loc.String()) } @@ -436,9 +440,9 @@ func TestConfigV1_MigratedToV2_TimezoneDefaultsToUTC(t *testing.T) { kegv: "2023-01" title: "V1 KEG" ` - config, err := keg.ParseKegConfig([]byte(v1Yaml)) + settings, err := keg.ParseKegSettings([]byte(v1Yaml)) require.NoError(t, err) - require.Equal(t, "UTC", config.Timezone, "V1 migrated to V2 should default timezone to UTC") + require.Equal(t, "UTC", settings.Timezone, "V1 migrated to V2 should default timezone to UTC") } func indexFiles(entries []keg.IndexEntry) []string { diff --git a/pkg/keg/keg_snapshots_test.go b/pkg/keg/keg_snapshots_test.go index 517224b6..eaecf4b6 100644 --- a/pkg/keg/keg_snapshots_test.go +++ b/pkg/keg/keg_snapshots_test.go @@ -15,7 +15,7 @@ type repoWithoutSnapshots struct { func TestKegSnapshotsRestoreSkipsSchemaEnforcement(t *testing.T) { fx := NewSandbox(t) ctx := fx.Context() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) initNonStrictTestKeg(t, k, ctx) id, err := k.Create(kegpkg.WithValidationMode(ctx, kegpkg.ValidationModeOff), &kegpkg.CreateOptions{ @@ -25,7 +25,7 @@ func TestKegSnapshotsRestoreSkipsSchemaEnforcement(t *testing.T) { require.NoError(t, err) snap, err := k.AppendSnapshot(ctx, id.ID, "before schema") require.NoError(t, err) - require.NoError(t, k.WriteSchema(ctx, "task", []byte(`type: task + require.NoError(t, k.CreateSchema(ctx, "task", []byte(`type: task meta: type: object required: ["type"] @@ -56,7 +56,7 @@ func TestKegSnapshots_ReturnErrNotSupportedWithoutSnapshotBackend(t *testing.T) t.Parallel() fx := NewSandbox(t) - base := kegpkg.NewMemoryRepo(fx.Runtime()) + base := newTestMemoryRepo(fx.Runtime()) repo := &repoWithoutSnapshots{Repository: base} k := kegpkg.NewLocalKeg(repo, fx.Runtime()) diff --git a/pkg/keg/keg_test.go b/pkg/keg/keg_test.go index 7c72941b..9464be5c 100644 --- a/pkg/keg/keg_test.go +++ b/pkg/keg/keg_test.go @@ -7,28 +7,28 @@ import ( "time" "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/internal/testkegrepo" kegpkg "github.com/jlrickert/tapper/pkg/keg" "github.com/stretchr/testify/require" ) type externalMemoryRepo struct { - *kegpkg.MemoryRepo + *testkegrepo.MemoryRepository } func (r *externalMemoryRepo) Name() string { - return "external-memory" + return "external-fs" } -// TestInitWhenRepoIsExample attempts to InitKeg a keg when the repo already -// contains the example data. InitKeg should fail with ErrExist. -func TestInitWhenRepoIsExample(t *testing.T) { +// TestInitWhenRepoExists verifies a second Init reports ErrExist. +func TestInitWhenRepoExists(t *testing.T) { t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("example", "~/repos/example")) + f := NewSandbox(t) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("~/repos/example"), f.Runtime()) - require.NoError(t, err, "NewKegFromTarget failed") + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) + require.NoError(t, k.Init(f.Context())) - err = k.Init(f.Context()) + err := k.Init(f.Context()) require.Error(t, err) require.Truef( t, @@ -43,12 +43,12 @@ func TestInitOnEmptyRepo(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo"), f.Runtime()) require.NoError(t, err, "NewKegFromTarget failed") initNonStrictTestKeg(t, k, f.Context()) - cfg, err := k.Config(f.Context()) + cfg, err := k.Settings(f.Context()) require.NoError(t, err) require.Equal(t, f.Now().Format(time.RFC3339), cfg.Updated) @@ -70,37 +70,11 @@ func TestInitOnEmptyRepo(t *testing.T) { require.True(t, foundZero, "expected zero node to exist after InitKeg") } -// TestKegExistsWithMemoryRepo verifies KegExists behavior with the in-memory -// repository. It should report false for an uninitialized repo and true after -// InitKeg has been called. -func TestKegExistsWithMemoryRepo(t *testing.T) { - t.Parallel() - f := NewSandbox(t) - - repo := kegpkg.NewMemoryRepo(f.Runtime()) - - // Initially not initialized. - exists, err := kegpkg.RepoContainsKeg(f.Context(), repo) - require.NoError(t, err) - require.False(t, exists, "expected KegExists false for new memory repo") - - // Initialize via Keg.InitKeg and re-check. - k := kegpkg.NewLocalKeg(repo, f.Runtime()) - initNonStrictTestKeg(t, k, f.Context()) - - exists, err = kegpkg.RepoContainsKeg(f.Context(), repo) - require.NoError(t, err) - require.True(t, exists, "expected KegExists true after InitKeg") -} - -// TestKegExistsWithFsRepo verifies KegExists behavior using the filesystem -// repository. It uses the provided empty fixture and ensures behavior mirrors -// the memory repo. -func TestKegExistsWithFsRepo(t *testing.T) { +func TestKegExistsWithMemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repofs")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repofs"), f.Runtime()) require.NoError(t, err, "NewKegFromTarget failed") // Uninitialized on disk. @@ -118,14 +92,13 @@ func TestKegExistsWithFsRepo(t *testing.T) { // Additional tests -// TestCreateZeroNodeInMemoryRepo verifies creating the zero node via Create -// on a fresh in-memory repository. The zero node should contain the -// RawZeroNodeContent. -func TestCreateZeroNodeInMemoryRepo(t *testing.T) { +// TestCreateZeroNodeInMemoryRepository verifies creating the zero node via +// Create on a fresh repository. +func TestCreateZeroNodeInMemoryRepository(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -140,7 +113,7 @@ func TestCreateNodeWithMeta(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -173,7 +146,7 @@ func TestCreateWithBody(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -201,7 +174,7 @@ func TestCreateWithBodyFrontmatter(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -241,7 +214,7 @@ func TestSetContentAndUpdate(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -261,15 +234,15 @@ func TestSetContentAndUpdate(t *testing.T) { require.Equal(t, "updated lead paragraph", stats.Lead()) } -// TestCreateAndUpdateNodesWithFsRepo uses the filesystem repo to create a +// TestCreateAndUpdateNodesWithMemoryRepository uses the filesystem repo to create a // node, ensures the dex contains the node, updates content, and validates // meta and dex timestamps reflect the update. -func TestCreateAndUpdateNodesWithFsRepo(t *testing.T) { +func TestCreateAndUpdateNodesWithMemoryRepository(t *testing.T) { t.Parallel() // Use the empty fixture as a filesystem-backed repo. f := NewSandbox(t, sandbox.WithFixture("empty", "repofs_fs")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs_fs"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repofs_fs"), f.Runtime()) require.NoError(t, err, "NewKegFromTarget failed") // Initialize on disk. @@ -326,7 +299,7 @@ func TestNodesWithTagsAndInterlinks(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -399,75 +372,55 @@ func TestNodesWithTagsAndInterlinks(t *testing.T) { require.Equal(t, idA.ID.ID, inB[0].ID) } -// TestIndexFilesHaveExpectedData verifies the repository index artifacts that -// live under dex/ are present or handled correctly by the code that reads them. -// The example fixture contains `dex/nodes.tsv` and `dex/changes.md`. Tags and -// backlinks may be absent and should be treated as empty. -func TestIndexFilesHaveExpectedData(t *testing.T) { +func TestMarkdownLinkCreatesBacklinkWhileBareKegProseDoesNot(t *testing.T) { t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("example", "~/repo")) + f := NewSandbox(t) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("~/repo"), f.Runtime()) - require.NoError(t, err, "NewKegFromTarget failed") + repo := newTestMemoryRepo(f.Runtime()) + k := kegpkg.NewLocalKeg(repo, f.Runtime()) + initNonStrictTestKeg(t, k, f.Context()) - // Load dex via NewDexFromRepo which reads the index artifacts. - dex, err := kegpkg.NewDexFromRepo(f.Context(), k.(*kegpkg.LocalKeg).Repo) - require.NoError(t, err, "NewDexFromRepo failed") + one, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "One"}) + require.NoError(t, err) + two, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Two"}) + require.NoError(t, err) + three, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Three"}) + require.NoError(t, err) - // nodes.tsv should contain the zero node entry. - zeroRef := dex.GetRef(f.Context(), kegpkg.NodeId{ID: 0}) - require.NotNil(t, zeroRef, "nodes.tsv should include zero node entry") - - // changes.md is expected to exist in the example fixture under dex/. - changes, err := k.(*kegpkg.LocalKeg).Repo.GetIndex(f.Context(), "changes.md") - require.NoError(t, err, "expected dex/changes.md to exist") - require.Greater(t, len(changes), 0, "dex/changes.md should not be empty") - - // tags may be absent for the example fixture. If absent, Dex.TagList should - // be empty. If present, ensure we can read it without error. - if _, err := k.(*kegpkg.LocalKeg).Repo.GetIndex(f.Context(), "tags"); err != nil { - require.True(t, errors.Is(err, kegpkg.ErrNotExist), - "expected missing tags index to return ErrNotExist, got: %v", err) - require.Empty(t, dex.TagList(f.Context()), "expected no tags when tags index is absent") - } else { - // tags file present, ensure parsed tag list is stable. - require.GreaterOrEqual(t, len(dex.TagList(f.Context())), 0) - } + require.NoError(t, k.SetContent(f.Context(), one.ID, []byte( + "# One\n\n[Two](../2) is a graph link. Bare keg:example/3 is prose.\n", + ))) + dex, err := k.Dex(f.Context()) + require.NoError(t, err) - // backlinks may be absent. If absent, expect no backlinks for the zero node. - if _, err := k.(*kegpkg.LocalKeg).Repo.GetIndex(f.Context(), "backlinks"); err != nil { - require.True(t, errors.Is(err, kegpkg.ErrNotExist), - "expected missing backlinks index to return ErrNotExist, got: %v", err) - _, ok := dex.Backlinks(f.Context(), kegpkg.NodeId{ID: 0}) - require.False(t, ok, "expected no backlinks for zero when index is absent") - } else { - // backlinks file present, ensure parsing did not error earlier and that - // the dex can return a backlinks mapping (possibly empty). - _, _ = dex.Backlinks(f.Context(), kegpkg.NodeId{ID: 0}) - } + backlinks, ok := dex.Backlinks(f.Context(), two.ID) + require.True(t, ok) + require.Equal(t, []kegpkg.NodeId{one.ID}, backlinks) + _, ok = dex.Backlinks(f.Context(), three.ID) + require.False(t, ok, "bare keg: prose must not create a backlink") } func TestIndex_PreservesUnknownConfigFields(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repofs_config")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs_config"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repofs_config"), f.Runtime()) require.NoError(t, err, "NewKegFromTarget failed") initNonStrictTestKeg(t, k, f.Context()) - _, err = k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Config Field Preservation"}) + _, err = k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Settings Field Preservation"}) require.NoError(t, err) - customConfig := []byte(`kegv: "2025-07" + customSettings := []byte(`kegv: "2025-07" updated: "2020-01-01T00:00:00Z" -title: "custom config" +title: "custom settings" summary: "contains unknown fields" custom_block: keep_me: true nested: item: value `) - require.NoError(t, f.Runtime().WriteFile("repofs_config/keg", customConfig, 0o644)) + require.NoError(t, f.Runtime().WriteFile("repofs_config/keg", customSettings, 0o644)) require.NoError(t, k.Index(f.Context(), kegpkg.IndexOptions{})) @@ -479,7 +432,7 @@ custom_block: require.Contains(t, out, "nested:") require.Contains(t, out, "item: value") - cfg, err := k.(*kegpkg.LocalKeg).Repo.ReadConfig(f.Context()) + cfg, err := k.(*kegpkg.LocalKeg).Repo.ReadSettings(f.Context()) require.NoError(t, err) require.NotEqual(t, "2020-01-01T00:00:00Z", cfg.Updated) } @@ -488,7 +441,7 @@ func TestMove_RewritesLinksAndUpdatesDex(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -503,7 +456,7 @@ func TestMove_RewritesLinksAndUpdatesDex(t *testing.T) { // Add canonical and bare links to node 2. require.NoError(t, k.SetContent(f.Context(), id1.ID, []byte("# One\n\nSee [two](../2).\nAlso ../2.\n"))) - require.NoError(t, errOnly(k.Move(f.Context(), kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 3}))) + require.NoError(t, errOnly(k.Move(f.Context(), moveOptions(t, f.Context(), k, kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 3})))) exists, err := k.Repo.HasNode(f.Context(), kegpkg.NodeId{ID: 2}) require.NoError(t, err) @@ -537,7 +490,7 @@ func TestMove_DestinationExists(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -548,7 +501,7 @@ func TestMove_DestinationExists(t *testing.T) { _, err = k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Three"}) require.NoError(t, err) - _, err = k.Move(f.Context(), kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 3}) + _, err = k.Move(f.Context(), moveOptions(t, f.Context(), k, kegpkg.NodeId{ID: 2}, kegpkg.NodeId{ID: 3})) require.Error(t, err) require.ErrorIs(t, err, kegpkg.ErrDestinationExists) } @@ -557,7 +510,7 @@ func TestRemove_DeletesNodeAndUpdatesDex(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -568,7 +521,7 @@ func TestRemove_DeletesNodeAndUpdatesDex(t *testing.T) { require.NoError(t, k.SetContent(f.Context(), id1.ID, []byte("# One\n\nSee [two](../2).\n"))) - require.NoError(t, errOnly(k.Remove(f.Context(), id2.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id2.ID)))) exists, err := k.Repo.HasNode(f.Context(), id2.ID) require.NoError(t, err) @@ -596,14 +549,14 @@ func TestSetContent_OnRemovedNode(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Title: "Doomed"}) require.NoError(t, err) - require.NoError(t, errOnly(k.Remove(f.Context(), id.ID))) + require.NoError(t, errOnly(k.Remove(f.Context(), removeOptions(t, f.Context(), k, id.ID)))) // Attempt to write content to the removed node should fail. err = k.SetContent(f.Context(), id.ID, []byte("# Resurrected\n")) @@ -615,11 +568,11 @@ func TestRemove_NotFound(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) - _, err := k.Remove(f.Context(), kegpkg.NodeId{ID: 4242}) + _, err := k.Remove(f.Context(), kegpkg.NodeRemoveOptions{ID: kegpkg.NodeId{ID: 4242}, ExpectedHash: "missing"}) require.Error(t, err) require.ErrorIs(t, err, kegpkg.ErrNotExist) } @@ -631,7 +584,7 @@ func TestSetMeta_PreservesLinksInDex(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -676,7 +629,7 @@ func TestIndex_ContentOnlyNodeGetsIndexed(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -703,7 +656,7 @@ func TestIndex_MalformedMetaNodeGetsIndexed(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -729,12 +682,12 @@ func TestIndex_MalformedMetaNodeGetsIndexed(t *testing.T) { } // TestSetContent_NoChangeSkipsDexAndConfig verifies that calling SetContent -// with identical content does not modify the dex or keg config timestamp. +// with identical content does not modify the dex or keg settings timestamp. func TestSetContent_NoChangeSkipsDexAndConfig(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_noop")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_noop"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_noop"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -742,8 +695,8 @@ func TestSetContent_NoChangeSkipsDexAndConfig(t *testing.T) { id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Body: body}) require.NoError(t, err) - // Record keg config updated timestamp after create. - cfg1, err := k.Config(f.Context()) + // Record keg settings updated timestamp after create. + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterCreate := cfg1.Updated @@ -753,20 +706,20 @@ func TestSetContent_NoChangeSkipsDexAndConfig(t *testing.T) { // SetContent with identical bytes — should be a no-op. require.NoError(t, k.SetContent(f.Context(), id.ID, body)) - // Config timestamp should not have changed. - cfg2, err := k.Config(f.Context()) + // Settings timestamp should not have changed. + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.Equal(t, updatedAfterCreate, cfg2.Updated, - "keg config updated timestamp should not change when content is unchanged") + "keg settings updated timestamp should not change when content is unchanged") } // TestSetMeta_NoChangeSkipsDexAndConfig verifies that calling SetMeta -// with identical metadata does not modify the dex or keg config timestamp. +// with identical metadata does not modify the dex or keg settings timestamp. func TestSetMeta_NoChangeSkipsDexAndConfig(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_meta_noop")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_meta_noop"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_meta_noop"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -783,8 +736,8 @@ func TestSetMeta_NoChangeSkipsDexAndConfig(t *testing.T) { require.NoError(t, err) require.NoError(t, k.SetMeta(f.Context(), id.ID, meta)) - // Record keg config updated timestamp after normalization. - cfg1, err := k.Config(f.Context()) + // Record keg settings updated timestamp after normalization. + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterNormalize := cfg1.Updated @@ -796,20 +749,20 @@ func TestSetMeta_NoChangeSkipsDexAndConfig(t *testing.T) { require.NoError(t, err) require.NoError(t, k.SetMeta(f.Context(), id.ID, meta)) - // Config timestamp should not have changed. - cfg2, err := k.Config(f.Context()) + // Settings timestamp should not have changed. + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.Equal(t, updatedAfterNormalize, cfg2.Updated, - "keg config updated timestamp should not change when meta is unchanged") + "keg settings updated timestamp should not change when meta is unchanged") } // TestSetMeta_WithChangeUpdatesDexAndConfig verifies that calling SetMeta -// with different metadata does update the dex and keg config timestamp. +// with different metadata does update the dex and keg settings timestamp. func TestSetMeta_WithChangeUpdatesDexAndConfig(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_meta_change")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_meta_change"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_meta_change"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -819,8 +772,8 @@ func TestSetMeta_WithChangeUpdatesDexAndConfig(t *testing.T) { }) require.NoError(t, err) - // Record keg config updated timestamp after create. - cfg1, err := k.Config(f.Context()) + // Record keg settings updated timestamp after create. + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterCreate := cfg1.Updated @@ -834,13 +787,13 @@ func TestSetMeta_WithChangeUpdatesDexAndConfig(t *testing.T) { meta.SetTags([]string{"new-tag"}) require.NoError(t, k.SetMeta(f.Context(), id.ID, meta)) - // Config timestamp should have been updated. - cfg2, err := k.Config(f.Context()) + // Settings timestamp should have been updated. + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.NotEqual(t, updatedAfterCreate, cfg2.Updated, - "keg config updated timestamp should change when meta is modified") + "keg settings updated timestamp should change when meta is modified") require.Equal(t, expectedUpdated, cfg2.Updated, - "keg config updated timestamp should use the captured metadata update time") + "keg settings updated timestamp should use the captured metadata update time") // Verify the tag actually changed in the dex. dex, err := k.Dex(f.Context()) @@ -852,7 +805,7 @@ func TestSetMeta_WithChangeUpdatesDexAndConfig(t *testing.T) { func TestSetMetaAndUpdateMetaRefreshCachedSourceHash(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -879,10 +832,10 @@ func TestSetMetaAndUpdateMetaRefreshCachedSourceHash(t *testing.T) { updatedStats, err := k.GetStats(f.Context(), id.ID) require.NoError(t, err) require.NotEqual(t, setStats.Hash(), updatedStats.Hash()) - cfg, err := k.Config(f.Context()) + cfg, err := k.Settings(f.Context()) require.NoError(t, err) require.Equal(t, expectedUpdateMetaConfig, cfg.Updated, - "keg config updated timestamp should use the captured UpdateMeta time") + "keg settings updated timestamp should use the captured UpdateMeta time") content, err := k.GetContent(f.Context(), id.ID) require.NoError(t, err) @@ -892,7 +845,7 @@ func TestSetMetaAndUpdateMetaRefreshCachedSourceHash(t *testing.T) { func TestIndexRefreshesStatsForOutOfBandMetadataChange(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(f.Runtime()) + repo := newTestMemoryRepo(f.Runtime()) k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -928,12 +881,12 @@ func TestIndexRefreshesStatsForOutOfBandMetadataChange(t *testing.T) { } // TestSetContent_WithChangeUpdatesDexAndConfig verifies that calling SetContent -// with different content does update the dex and keg config timestamp. +// with different content does update the dex and keg settings timestamp. func TestSetContent_WithChangeUpdatesDexAndConfig(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_content_change")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_content_change"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_content_change"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -941,8 +894,8 @@ func TestSetContent_WithChangeUpdatesDexAndConfig(t *testing.T) { id, err := k.Create(f.Context(), &kegpkg.CreateOptions{Body: body}) require.NoError(t, err) - // Record keg config updated timestamp after create. - cfg1, err := k.Config(f.Context()) + // Record keg settings updated timestamp after create. + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterCreate := cfg1.Updated @@ -950,17 +903,17 @@ func TestSetContent_WithChangeUpdatesDexAndConfig(t *testing.T) { f.Advance(5 * time.Minute) expectedUpdated := f.Now().Format(time.RFC3339) - // SetContent with different bytes — should update dex and config. + // SetContent with different bytes — should update dex and settings. newBody := []byte("# Change Node\n\nUpdated content.\n") require.NoError(t, k.SetContent(f.Context(), id.ID, newBody)) - // Config timestamp should have been updated. - cfg2, err := k.Config(f.Context()) + // Settings timestamp should have been updated. + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.NotEqual(t, updatedAfterCreate, cfg2.Updated, - "keg config updated timestamp should change when content is modified") + "keg settings updated timestamp should change when content is modified") require.Equal(t, expectedUpdated, cfg2.Updated, - "keg config updated timestamp should use the helper's current clock time") + "keg settings updated timestamp should use the helper's current clock time") // Verify content was actually written. got, err := k.GetContent(f.Context(), id.ID) @@ -971,12 +924,12 @@ func TestSetContent_WithChangeUpdatesDexAndConfig(t *testing.T) { // TestEditNoChange_SimulatesSaveWithoutChanges simulates the tap edit // flow where SetMeta and SetContent are called with unchanged data. // After the first normalization round-trip, neither the dex files nor the -// keg config should be modified on a second save-without-changes. +// keg settings should be modified on a second save-without-changes. func TestEditNoChange_SimulatesSaveWithoutChanges(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_edit_noop")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_edit_noop"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_edit_noop"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) @@ -994,8 +947,8 @@ func TestEditNoChange_SimulatesSaveWithoutChanges(t *testing.T) { require.NoError(t, k.SetMeta(f.Context(), id.ID, meta)) require.NoError(t, k.SetContent(f.Context(), id.ID, body)) - // Record keg config updated timestamp after normalization. - cfg1, err := k.Config(f.Context()) + // Record keg settings updated timestamp after normalization. + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterNormalize := cfg1.Updated @@ -1009,24 +962,24 @@ func TestEditNoChange_SimulatesSaveWithoutChanges(t *testing.T) { require.NoError(t, k.SetMeta(f.Context(), id.ID, meta)) require.NoError(t, k.SetContent(f.Context(), id.ID, body)) - // Config timestamp should not have changed. - cfg2, err := k.Config(f.Context()) + // Settings timestamp should not have changed. + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.Equal(t, updatedAfterNormalize, cfg2.Updated, - "keg config should not change when editing saves without modifications") + "keg settings should not change when editing saves without modifications") } // TestCreateAlwaysTriggersUpdate verifies that Create always updates dex -// and config, regardless of content. +// and settings, regardless of content. func TestCreateAlwaysTriggersUpdate(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo_create_always")) - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repo_create_always"), f.Runtime()) + k, err := newMemoryKegFromTarget(f.Context(), memoryTarget("repo_create_always"), f.Runtime()) require.NoError(t, err) initNonStrictTestKeg(t, k, f.Context()) - cfg1, err := k.Config(f.Context()) + cfg1, err := k.Settings(f.Context()) require.NoError(t, err) updatedAfterInit := cfg1.Updated @@ -1036,105 +989,18 @@ func TestCreateAlwaysTriggersUpdate(t *testing.T) { _, err = k.Create(f.Context(), &kegpkg.CreateOptions{Title: "New Node"}) require.NoError(t, err) - cfg2, err := k.Config(f.Context()) + cfg2, err := k.Settings(f.Context()) require.NoError(t, err) require.NotEqual(t, updatedAfterInit, cfg2.Updated, - "keg config should always update after Create") + "keg settings should always update after Create") require.Equal(t, expectedUpdated, cfg2.Updated, - "keg config updated timestamp should use the captured create time") -} - -// TestDexFresh_ReloadsAfterExternalModification verifies that DexFresh -// detects when the on-disk dex has been modified by an external process and -// reloads it. This is the core mechanism that makes the serve handler show -// fresh data without a server restart. -func TestDexFresh_ReloadsAfterExternalModification(t *testing.T) { - t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("empty", "repofs_dexfresh")) - - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs_dexfresh"), f.Runtime()) - require.NoError(t, err) - initNonStrictTestKeg(t, k, f.Context()) - - // Create a node so the dex has content. - id, err := k.Create(f.Context(), &kegpkg.CreateOptions{ - Title: "Original Node", - Lead: "original lead", - Tags: []string{"alpha"}, - }) - require.NoError(t, err) - - // Load the dex via DexFresh and verify initial state. - dex1, err := k.Dex(f.Context()) - require.NoError(t, err) - ref1 := dex1.GetRef(f.Context(), id.ID) - require.NotNil(t, ref1) - require.Equal(t, "Original Node", ref1.Title) - - // Simulate an external process creating a second node by directly - // using a second Keg instance pointing at the same repo. This writes - // new dex files to disk, changing the mtime. - f.Advance(2 * time.Minute) - k2, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs_dexfresh"), f.Runtime()) - require.NoError(t, err) - _, err = k2.Create(f.Context(), &kegpkg.CreateOptions{ - Title: "External Node", - Lead: "added externally", - Tags: []string{"beta"}, - }) - require.NoError(t, err) - - // The original keg instance's cached dex is now stale. DexFresh should - // detect the mtime change and reload. - dex2, err := k.Dex(f.Context()) - require.NoError(t, err) - - // Verify the externally-added node appears. - extRef := dex2.GetRef(f.Context(), kegpkg.NodeId{ID: 2}) - require.NotNil(t, extRef, "DexFresh should reload and include the externally-added node") - require.Equal(t, "External Node", extRef.Title) - - // The original node should still be present. - origRef := dex2.GetRef(f.Context(), id.ID) - require.NotNil(t, origRef) - require.Equal(t, "Original Node", origRef.Title) - - // Verify tag index also refreshed. - tagList := dex2.TagList(f.Context()) - require.Contains(t, tagList, "alpha") - require.Contains(t, tagList, "beta") -} - -// TestDexFresh_ReturnsCachedWhenUnchanged verifies that DexFresh returns -// the same cached dex when no external modification has occurred, avoiding -// unnecessary reloads. -func TestDexFresh_ReturnsCachedWhenUnchanged(t *testing.T) { - t.Parallel() - f := NewSandbox(t, sandbox.WithFixture("empty", "repofs_dexcache")) - - k, err := kegpkg.NewKegFromTarget(f.Context(), kegpkg.NewFile("repofs_dexcache"), f.Runtime()) - require.NoError(t, err) - initNonStrictTestKeg(t, k, f.Context()) - - _, err = k.Create(f.Context(), &kegpkg.CreateOptions{ - Title: "Cached Node", - }) - require.NoError(t, err) - - // Load dex twice without any external changes. - dex1, err := k.Dex(f.Context()) - require.NoError(t, err) - dex2, err := k.Dex(f.Context()) - require.NoError(t, err) - - // Both should return the same pointer (no reload occurred). - require.Same(t, dex1, dex2, "DexFresh should return cached dex when mtime unchanged") + "keg settings updated timestamp should use the captured create time") } func TestDexFresh_ReloadsForExternalRepoImplementations(t *testing.T) { t.Parallel() f := NewSandbox(t) - repo := &externalMemoryRepo{MemoryRepo: kegpkg.NewMemoryRepo(f.Runtime())} + repo := &externalMemoryRepo{MemoryRepository: newTestMemoryRepo(f.Runtime())} k := kegpkg.NewLocalKeg(repo, f.Runtime()) initNonStrictTestKeg(t, k, f.Context()) @@ -1176,9 +1042,9 @@ func TestSetContent_LocalNodeIDStaysBare(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget( + k, err := newMemoryKegFromTarget( f.Context(), - kegpkg.NewFile("repo", withKegName("example")), + memoryTarget("repo", withKegName("example")), f.Runtime(), ) require.NoError(t, err) @@ -1213,9 +1079,9 @@ func TestMove_LocalNodeIDStaysBare(t *testing.T) { t.Parallel() f := NewSandbox(t, sandbox.WithFixture("empty", "repo")) - k, err := kegpkg.NewKegFromTarget( + k, err := newMemoryKegFromTarget( f.Context(), - kegpkg.NewFile("repo", withKegName("example")), + memoryTarget("repo", withKegName("example")), f.Runtime(), ) require.NoError(t, err) @@ -1231,7 +1097,7 @@ func TestMove_LocalNodeIDStaysBare(t *testing.T) { []byte("# Referrer\n\nsee [target](../"+target.ID.Path()+")\n"))) // Move the target; this rewrites referrer's link and re-indexes it. - require.NoError(t, errOnly(k.Move(f.Context(), target.ID, kegpkg.NodeId{ID: target.ID.ID + 10}))) + require.NoError(t, errOnly(k.Move(f.Context(), moveOptions(t, f.Context(), k, target.ID, kegpkg.NodeId{ID: target.ID.ID + 10})))) for _, name := range []string{"nodes.tsv", "links", "backlinks"} { raw, err := k.(*kegpkg.LocalKeg).Repo.GetIndex(f.Context(), name) diff --git a/pkg/keg/memory_repository_test.go b/pkg/keg/memory_repository_test.go new file mode 100644 index 00000000..3df26630 --- /dev/null +++ b/pkg/keg/memory_repository_test.go @@ -0,0 +1,969 @@ +package keg + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "time" + + "github.com/jlrickert/cli-toolkit/toolkit" +) + +// testMemoryRepository is an in-memory Repository used only by this package's +// tests. PostgreSQL remains the sole production LocalKeg repository. +type testMemoryRepository struct { + runtime *toolkit.Runtime + + boundary sync.RWMutex + mu sync.RWMutex + nodes map[NodeId]*memoryNode + reserved map[NodeId]struct{} + indexes map[string][]byte + settings []byte + schemas map[string][]byte + snaps map[NodeId][]memorySnapshot + locks map[NodeId]LockInfo + nodeMu map[NodeId]*sync.Mutex + + watchersMu sync.Mutex + watchers map[*memoryWatcher]struct{} +} + +type memoryNode struct { + content []byte + meta []byte + stats *NodeStats + files map[string][]byte + images map[string][]byte +} + +type memorySnapshot struct { + snapshot Snapshot + content []byte + meta []byte + stats *NodeStats +} + +type memoryWatcher struct { + ids map[NodeId]struct{} + ch chan NodeEvent +} + +type memoryBoundaryKey struct{} + +type memoryBoundary struct { + owner *testMemoryRepository + write bool +} + +// newTestMemoryRepository returns a concurrency-safe test repository. +func newTestMemoryRepository(rt *toolkit.Runtime) *testMemoryRepository { + return &testMemoryRepository{ + runtime: rt, + nodes: make(map[NodeId]*memoryNode), + reserved: make(map[NodeId]struct{}), + indexes: make(map[string][]byte), + schemas: make(map[string][]byte), + snaps: make(map[NodeId][]memorySnapshot), + locks: make(map[NodeId]LockInfo), + nodeMu: make(map[NodeId]*sync.Mutex), + watchers: make(map[*memoryWatcher]struct{}), + } +} + +func (r *testMemoryRepository) Name() string { return "memory-test" } + +func (r *testMemoryRepository) WithKegRead(ctx context.Context, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if held, _ := ctx.Value(memoryBoundaryKey{}).(memoryBoundary); held.owner == r { + return fn(ctx) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %w", ErrLockTimeout, err) + } + r.boundary.RLock() + defer r.boundary.RUnlock() + return fn(context.WithValue(ctx, memoryBoundaryKey{}, memoryBoundary{owner: r})) +} + +func (r *testMemoryRepository) WithKegWrite(ctx context.Context, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if held, _ := ctx.Value(memoryBoundaryKey{}).(memoryBoundary); held.owner == r { + if !held.write { + return ErrKegLockUpgrade + } + return fn(ctx) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %w", ErrLockTimeout, err) + } + r.boundary.Lock() + defer r.boundary.Unlock() + return fn(context.WithValue(ctx, memoryBoundaryKey{}, memoryBoundary{owner: r, write: true})) +} + +func (r *testMemoryRepository) SupportsConcurrentAccess(context.Context) bool { return true } + +func (r *testMemoryRepository) HasNode(ctx context.Context, id NodeId) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.nodes[id] + return ok, nil +} + +func (r *testMemoryRepository) Next(ctx context.Context) (NodeId, error) { + if err := ctx.Err(); err != nil { + return NodeId{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + maxID := -1 + for id := range r.nodes { + if id.Code == "" && id.ID > maxID { + maxID = id.ID + } + } + for id := range r.reserved { + if id.Code == "" && id.ID > maxID { + maxID = id.ID + } + } + id := NodeId{ID: maxID + 1} + r.reserved[id] = struct{}{} + return id, nil +} + +func (r *testMemoryRepository) ListNodes(ctx context.Context) ([]NodeId, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r.mu.RLock() + defer r.mu.RUnlock() + ids := make([]NodeId, 0, len(r.nodes)) + for id := range r.nodes { + ids = append(ids, id) + } + slices.SortFunc(ids, func(a, b NodeId) int { return a.Compare(b) }) + return ids, nil +} + +func (r *testMemoryRepository) MoveNode(ctx context.Context, id, dst NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node, ok := r.nodes[id] + if !ok { + return ErrNotExist + } + if _, exists := r.nodes[dst]; exists { + return ErrDestinationExists + } + r.nodes[dst] = node + delete(r.nodes, id) + delete(r.reserved, dst) + if snaps := r.snaps[id]; snaps != nil { + for i := range snaps { + snaps[i].snapshot.Node = dst + } + r.snaps[dst] = snaps + delete(r.snaps, id) + } + return nil +} + +func (r *testMemoryRepository) DeleteNode(ctx context.Context, id NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.nodes[id]; !ok { + return ErrNotExist + } + delete(r.nodes, id) + delete(r.reserved, id) + delete(r.snaps, id) + delete(r.locks, id) + return nil +} + +func (r *testMemoryRepository) WithNodeLock(ctx context.Context, id NodeId, fn func(context.Context) error) error { + if fn == nil { + return fmt.Errorf("fn required") + } + if contextHasNodeLock(ctx, id) { + return fn(ctx) + } + r.mu.Lock() + lock := r.nodeMu[id] + if lock == nil { + lock = &sync.Mutex{} + r.nodeMu[id] = lock + } + r.mu.Unlock() + acquired := make(chan struct{}) + go func() { + lock.Lock() + close(acquired) + }() + select { + case <-ctx.Done(): + go func() { <-acquired; lock.Unlock() }() + return fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) + case <-acquired: + } + defer lock.Unlock() + return fn(contextWithNodeLock(ctx, id)) +} + +func (r *testMemoryRepository) ReadContent(ctx context.Context, id NodeId) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r.mu.RLock() + node := r.nodes[id] + var out []byte + if node != nil { + out = cloneBytes(node.content) + } + r.mu.RUnlock() + if node == nil { + return nil, ErrNotExist + } + r.Emit(NodeEvent{Kind: NodeEventAccessed, NodeID: id, Field: "content"}) + return out, nil +} + +func (r *testMemoryRepository) WriteContent(ctx context.Context, id NodeId, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + node, existed := r.nodes[id] + if node == nil { + node = newMemoryNode() + r.nodes[id] = node + } + node.content = cloneBytes(data) + delete(r.reserved, id) + r.mu.Unlock() + kind := NodeEventModified + if !existed { + kind = NodeEventCreated + } + r.Emit(NodeEvent{Kind: kind, NodeID: id, Field: "content"}) + return nil +} + +func (r *testMemoryRepository) ReadMeta(ctx context.Context, id NodeId) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + return cloneBytes(node.meta), nil +} + +func (r *testMemoryRepository) WriteMeta(ctx context.Context, id NodeId, data []byte) error { + return r.updateNode(ctx, id, func(node *memoryNode) { node.meta = cloneBytes(data) }) +} + +func (r *testMemoryRepository) ReadStats(ctx context.Context, id NodeId) (*NodeStats, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil || node.stats == nil { + return nil, ErrNotExist + } + return cloneStats(ctx, node.stats) +} + +func (r *testMemoryRepository) WriteStats(ctx context.Context, id NodeId, stats *NodeStats) error { + copyStats, err := cloneStats(ctx, stats) + if err != nil { + return err + } + return r.updateNode(ctx, id, func(node *memoryNode) { node.stats = copyStats }) +} + +func (r *testMemoryRepository) updateNode(ctx context.Context, id NodeId, update func(*memoryNode)) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + update(node) + return nil +} + +func (r *testMemoryRepository) ReadMetaBatch(ctx context.Context, ids []NodeId) (map[string][]byte, error) { + out := make(map[string][]byte) + for _, id := range ids { + raw, err := r.ReadMeta(ctx, id) + if err == nil { + out[id.Path()] = raw + } else if !errors.Is(err, ErrNotExist) { + return nil, err + } + } + return out, nil +} + +func (r *testMemoryRepository) ReadStatsBatch(ctx context.Context, ids []NodeId) (map[string]*NodeStats, error) { + out := make(map[string]*NodeStats) + for _, id := range ids { + stats, err := r.ReadStats(ctx, id) + if err == nil { + out[id.Path()] = stats + } else if !errors.Is(err, ErrNotExist) { + return nil, err + } + } + return out, nil +} + +func (r *testMemoryRepository) GetIndex(ctx context.Context, name string) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + data, ok := r.indexes[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *testMemoryRepository) WriteIndex(ctx context.Context, name string, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.indexes[name] = cloneBytes(data) + return nil +} + +func (r *testMemoryRepository) ListIndexes(ctx context.Context) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + names := make([]string, 0, len(r.indexes)) + for name := range r.indexes { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *testMemoryRepository) ClearIndexes(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.indexes = make(map[string][]byte) + return nil +} + +func (r *testMemoryRepository) ReadSettings(ctx context.Context) (*Settings, error) { + raw, err := r.ReadSettingsDocument(ctx) + if err != nil { + return nil, err + } + return ParseKegSettings(raw) +} + +func (r *testMemoryRepository) WriteSettings(ctx context.Context, settings *Settings) error { + raw, err := settings.ToYAML() + if err != nil { + return err + } + return r.WriteSettingsDocument(ctx, raw) +} + +func (r *testMemoryRepository) ReadSettingsDocument(ctx context.Context) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + if r.settings == nil { + return nil, ErrNotExist + } + return cloneBytes(r.settings), nil +} + +func (r *testMemoryRepository) WriteSettingsDocument(ctx context.Context, data []byte) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.settings = cloneBytes(data) + return nil +} + +func (r *testMemoryRepository) ListSchemas(ctx context.Context) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + names := make([]string, 0, len(r.schemas)) + for name := range r.schemas { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *testMemoryRepository) ReadSchema(ctx context.Context, name string) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + data, ok := r.schemas[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *testMemoryRepository) CreateSchema(ctx context.Context, name string, data []byte) error { + if _, err := SchemaFilename(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.schemas[name]; ok { + return ErrExist + } + r.schemas[name] = cloneBytes(data) + return nil +} + +func (r *testMemoryRepository) WriteSchema(ctx context.Context, name string, data []byte) error { + if _, err := SchemaFilename(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.schemas[name] = cloneBytes(data) + return nil +} + +func (r *testMemoryRepository) DeleteSchema(ctx context.Context, name string) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.schemas[name]; !ok { + return ErrNotExist + } + delete(r.schemas, name) + return nil +} + +func (r *testMemoryRepository) ListFiles(ctx context.Context, id NodeId) ([]string, error) { + return r.listAssets(ctx, id, false) +} + +func (r *testMemoryRepository) ListImages(ctx context.Context, id NodeId) ([]string, error) { + return r.listAssets(ctx, id, true) +} + +func (r *testMemoryRepository) listAssets(ctx context.Context, id NodeId, images bool) ([]string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + names := make([]string, 0, len(assets)) + for name := range assets { + names = append(names, name) + } + slices.Sort(names) + return names, nil +} + +func (r *testMemoryRepository) ReadFile(ctx context.Context, id NodeId, name string) ([]byte, error) { + return r.readAsset(ctx, id, name, false) +} + +func (r *testMemoryRepository) ReadImage(ctx context.Context, id NodeId, name string) ([]byte, error) { + return r.readAsset(ctx, id, name, true) +} + +func (r *testMemoryRepository) readAsset(ctx context.Context, id NodeId, name string, images bool) ([]byte, error) { + if err := validAssetName(name); err != nil { + return nil, err + } + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + node := r.nodes[id] + if node == nil { + return nil, ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + data, ok := assets[name] + if !ok { + return nil, ErrNotExist + } + return cloneBytes(data), nil +} + +func (r *testMemoryRepository) WriteFile(ctx context.Context, id NodeId, name string, data []byte) error { + return r.writeAsset(ctx, id, name, data, false) +} + +func (r *testMemoryRepository) WriteImage(ctx context.Context, id NodeId, name string, data []byte) error { + return r.writeAsset(ctx, id, name, data, true) +} + +func (r *testMemoryRepository) writeAsset(ctx context.Context, id NodeId, name string, data []byte, images bool) error { + if err := validAssetName(name); err != nil { + return err + } + return r.updateNode(ctx, id, func(node *memoryNode) { + if images { + node.images[name] = cloneBytes(data) + } else { + node.files[name] = cloneBytes(data) + } + }) +} + +func (r *testMemoryRepository) DeleteFile(ctx context.Context, id NodeId, name string) error { + return r.deleteAsset(ctx, id, name, false) +} + +func (r *testMemoryRepository) DeleteImage(ctx context.Context, id NodeId, name string) error { + return r.deleteAsset(ctx, id, name, true) +} + +func (r *testMemoryRepository) deleteAsset(ctx context.Context, id NodeId, name string, images bool) error { + if err := validAssetName(name); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + assets := node.files + if images { + assets = node.images + } + if _, ok := assets[name]; !ok { + return ErrNotExist + } + delete(assets, name) + return nil +} + +func (r *testMemoryRepository) WithKegAtomicWrite(ctx context.Context, fn func(context.Context) error) error { + return r.WithKegWrite(ctx, func(writeCtx context.Context) error { + r.mu.Lock() + backup := r.cloneStateLocked() + r.mu.Unlock() + if err := fn(writeCtx); err != nil { + r.mu.Lock() + r.restoreStateLocked(backup) + r.mu.Unlock() + return err + } + return nil + }) +} + +type memoryState struct { + nodes map[NodeId]*memoryNode + reserved map[NodeId]struct{} + indexes map[string][]byte + settings []byte + schemas map[string][]byte + snaps map[NodeId][]memorySnapshot + locks map[NodeId]LockInfo +} + +func (r *testMemoryRepository) cloneStateLocked() memoryState { + state := memoryState{ + nodes: make(map[NodeId]*memoryNode), reserved: make(map[NodeId]struct{}), + indexes: make(map[string][]byte), settings: cloneBytes(r.settings), + schemas: make(map[string][]byte), snaps: make(map[NodeId][]memorySnapshot), + locks: make(map[NodeId]LockInfo), + } + for id, node := range r.nodes { + state.nodes[id] = cloneMemoryNode(node) + } + for id := range r.reserved { + state.reserved[id] = struct{}{} + } + for name, data := range r.indexes { + state.indexes[name] = cloneBytes(data) + } + for name, data := range r.schemas { + state.schemas[name] = cloneBytes(data) + } + for id, snaps := range r.snaps { + state.snaps[id] = cloneMemorySnapshots(snaps) + } + for id, info := range r.locks { + state.locks[id] = info + } + return state +} + +func (r *testMemoryRepository) restoreStateLocked(state memoryState) { + r.nodes, r.reserved, r.indexes = state.nodes, state.reserved, state.indexes + r.settings, r.schemas, r.snaps, r.locks = state.settings, state.schemas, state.snaps, state.locks +} + +func (r *testMemoryRepository) AppendSnapshot(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.nodes[id]; !ok { + return Snapshot{}, ErrNotExist + } + history := r.snaps[id] + parent := RevisionID(0) + if len(history) > 0 { + parent = history[len(history)-1].snapshot.ID + } + if in.ExpectedParent != parent { + return Snapshot{}, ErrConflict + } + content := cloneBytes(in.Content.Data) + if in.Content.Kind == SnapshotContentKindPatch { + var base []byte + for _, record := range history { + if record.snapshot.ID == in.Content.Base { + base = record.content + break + } + } + var err error + content, err = applySnapshotPatch(r.runtime.Hasher(), base, in.Content.Data) + if err != nil { + // Repository contract tests may provide already materialized content; + // LocalKeg supplies encoded line-patch data in normal operation. + content = cloneBytes(in.Content.Data) + } + } + createdAt := in.CreatedAt + if createdAt.IsZero() { + createdAt = r.runtime.Clock().Now() + } + statsBytes, err := snapshotStatsToBytes(in.Stats) + if err != nil { + return Snapshot{}, err + } + contentHash, metaHash, statsHash := snapshotWriteHashes(r.runtime, content, in.Meta, statsBytes) + snapshot := Snapshot{ + ID: RevisionID(len(history) + 1), Node: id, Parent: parent, + CreatedAt: createdAt, Message: in.Message, ContentHash: contentHash, + MetaHash: metaHash, StatsHash: statsHash, + IsCheckpoint: in.Content.Kind != SnapshotContentKindPatch, + } + stats, err := cloneStats(ctx, in.Stats) + if err != nil { + return Snapshot{}, err + } + r.snaps[id] = append(history, memorySnapshot{snapshot: snapshot, content: content, meta: cloneBytes(in.Meta), stats: stats}) + return snapshot, nil +} + +func (r *testMemoryRepository) GetSnapshot(ctx context.Context, id NodeId, rev RevisionID, opts SnapshotReadOptions) (Snapshot, []byte, []byte, *NodeStats, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return Snapshot{}, nil, nil, nil, err + } + record, ok := r.snapshotLocked(id, rev) + if !ok { + return Snapshot{}, nil, nil, nil, ErrNotExist + } + var content []byte + if opts.ResolveContent { + content = cloneBytes(record.content) + } + stats, err := cloneStats(ctx, record.stats) + return record.snapshot, content, cloneBytes(record.meta), stats, err +} + +func (r *testMemoryRepository) ListSnapshots(ctx context.Context, id NodeId) ([]Snapshot, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return nil, err + } + history := r.snaps[id] + out := make([]Snapshot, len(history)) + for i := range history { + out[i] = history[i].snapshot + } + return out, nil +} + +func (r *testMemoryRepository) ReadContentAt(ctx context.Context, id NodeId, rev RevisionID) ([]byte, error) { + _, content, _, _, err := r.GetSnapshot(ctx, id, rev, SnapshotReadOptions{ResolveContent: true}) + return content, err +} + +func (r *testMemoryRepository) RestoreSnapshot(ctx context.Context, id NodeId, rev RevisionID, createRestoreSnapshot bool) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.snapshotLocked(id, rev) + if !ok { + return ErrNotExist + } + node := r.nodes[id] + if node == nil { + return ErrNotExist + } + node.content = cloneBytes(record.content) + node.meta = cloneBytes(record.meta) + node.stats, _ = cloneStats(ctx, record.stats) + if createRestoreSnapshot { + history := r.snaps[id] + parent := history[len(history)-1].snapshot.ID + statsBytes, _ := snapshotStatsToBytes(record.stats) + contentHash, metaHash, statsHash := snapshotWriteHashes(r.runtime, record.content, record.meta, statsBytes) + snapshot := Snapshot{ + ID: RevisionID(len(history) + 1), Node: id, Parent: parent, + CreatedAt: r.runtime.Clock().Now(), Message: fmt.Sprintf("restore from rev %d", rev), + ContentHash: contentHash, MetaHash: metaHash, StatsHash: statsHash, IsCheckpoint: true, + } + r.snaps[id] = append(history, memorySnapshot{snapshot: snapshot, content: cloneBytes(record.content), meta: cloneBytes(record.meta), stats: record.stats}) + } + return nil +} + +func (r *testMemoryRepository) snapshotLocked(id NodeId, rev RevisionID) (memorySnapshot, bool) { + for _, record := range r.snaps[id] { + if record.snapshot.ID == rev { + return record, true + } + } + return memorySnapshot{}, false +} + +func (r *testMemoryRepository) corruptLatestSnapshot(id NodeId, mutate func(*Snapshot, *[]byte)) error { + r.mu.Lock() + defer r.mu.Unlock() + history := r.snaps[id] + if len(history) == 0 { + return ErrNotExist + } + latest := &history[len(history)-1] + mutate(&latest.snapshot, &latest.content) + r.snaps[id] = history + return nil +} + +func (r *testMemoryRepository) AcquireLock(ctx context.Context, id NodeId) (LockToken, error) { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + r.mu.Lock() + info := r.locks[id] + if info.Token == "" || info.IsStale(r.runtime.Clock().Now()) { + token := generateLockToken() + r.locks[id] = LockInfo{Token: token, AcquiredAt: r.runtime.Clock().Now(), TTLSeconds: int(DefaultLockTTL.Seconds()), Holder: "memory-test"} + r.mu.Unlock() + return token, nil + } + r.mu.Unlock() + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) + case <-ticker.C: + } + } +} + +func (r *testMemoryRepository) ReleaseLock(ctx context.Context, id NodeId, token LockToken) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + info, ok := r.locks[id] + if !ok || info.Token == "" { + return ErrNotLocked + } + if info.Token != token { + return ErrLockTokenMismatch + } + delete(r.locks, id) + return nil +} + +func (r *testMemoryRepository) LockStatus(ctx context.Context, id NodeId) (LockInfo, error) { + if err := ctx.Err(); err != nil { + return LockInfo{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + info := r.locks[id] + if info.Token == "" || info.IsStale(r.runtime.Clock().Now()) { + delete(r.locks, id) + return LockInfo{}, nil + } + return info, nil +} + +func (r *testMemoryRepository) ForceReleaseLock(ctx context.Context, id NodeId) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.locks, id) + return nil +} + +func (r *testMemoryRepository) Watch(ctx context.Context, ids ...NodeId) (<-chan NodeEvent, error) { + watcher := &memoryWatcher{ids: make(map[NodeId]struct{}), ch: make(chan NodeEvent, 32)} + for _, id := range ids { + watcher.ids[id] = struct{}{} + } + r.watchersMu.Lock() + r.watchers[watcher] = struct{}{} + r.watchersMu.Unlock() + go func() { + <-ctx.Done() + r.watchersMu.Lock() + if _, ok := r.watchers[watcher]; ok { + delete(r.watchers, watcher) + close(watcher.ch) + } + r.watchersMu.Unlock() + }() + return watcher.ch, nil +} + +func (r *testMemoryRepository) Emit(event NodeEvent) { + r.watchersMu.Lock() + defer r.watchersMu.Unlock() + for watcher := range r.watchers { + if len(watcher.ids) > 0 { + if _, ok := watcher.ids[event.NodeID]; !ok { + continue + } + } + select { + case watcher.ch <- event: + default: + } + } +} + +func newMemoryNode() *memoryNode { + return &memoryNode{files: make(map[string][]byte), images: make(map[string][]byte)} +} + +func cloneMemoryNode(node *memoryNode) *memoryNode { + copyNode := newMemoryNode() + copyNode.content, copyNode.meta = cloneBytes(node.content), cloneBytes(node.meta) + copyNode.stats, _ = cloneStats(context.Background(), node.stats) + for name, data := range node.files { + copyNode.files[name] = cloneBytes(data) + } + for name, data := range node.images { + copyNode.images[name] = cloneBytes(data) + } + return copyNode +} + +func cloneMemorySnapshots(in []memorySnapshot) []memorySnapshot { + out := make([]memorySnapshot, len(in)) + for i := range in { + out[i] = memorySnapshot{snapshot: in[i].snapshot, content: cloneBytes(in[i].content), meta: cloneBytes(in[i].meta)} + out[i].stats, _ = cloneStats(context.Background(), in[i].stats) + } + return out +} + +func cloneStats(ctx context.Context, stats *NodeStats) (*NodeStats, error) { + if stats == nil { + return nil, nil + } + raw, err := stats.ToJSON() + if err != nil { + return nil, err + } + return ParseStats(ctx, raw) +} + +var ( + _ Repository = (*testMemoryRepository)(nil) + _ RepositorySettingsDocuments = (*testMemoryRepository)(nil) + _ RepositoryAtomicWrite = (*testMemoryRepository)(nil) + _ RepositoryConcurrentAccess = (*testMemoryRepository)(nil) + _ RepositoryBatchRead = (*testMemoryRepository)(nil) + _ RepositoryFiles = (*testMemoryRepository)(nil) + _ RepositoryImages = (*testMemoryRepository)(nil) + _ RepositorySchemas = (*testMemoryRepository)(nil) + _ RepositorySnapshots = (*testMemoryRepository)(nil) + _ RepositoryLock = (*testMemoryRepository)(nil) + _ RepositoryEvents = (*testMemoryRepository)(nil) +) diff --git a/pkg/keg/node_ref.go b/pkg/keg/node_ref.go index 3d96a66f..0def3a51 100644 --- a/pkg/keg/node_ref.go +++ b/pkg/keg/node_ref.go @@ -6,10 +6,8 @@ import ( "strings" ) -// refSegmentPattern restricts the namespace and keg-name segments of a -// qualified node reference to a portable, filesystem-safe shape. It is the same -// shape tapper enforces for aliases and namespaces; the absence of a dot keeps -// reserved sentinels such as flights.d from ever appearing as a namespace. +// refSegmentPattern restricts namespace and keg-name segments to the portable +// Hub route shape used by aliases and namespaces. var refSegmentPattern = regexp.MustCompile(`^[a-z0-9_-]+$`) // RefForm enumerates the three shapes a node reference may take. @@ -19,7 +17,7 @@ const ( // RefLocal is a bare "" or "-" resolving against the current keg. RefLocal RefForm = iota // RefAlias is "keg:/[-]" — the alias resolves against the - // current keg's Links table (then the tap-config kegs map). + // current keg's Links table (then the tap-settings kegs map). RefAlias // RefQualified is "keg:@//[-]" — fully qualified; // the hub is implied from the current keg's hub. @@ -31,7 +29,7 @@ const ( // // - RefLocal: Node only; resolves against the current keg. // - RefAlias: Alias set; resolves against the current keg's Links table -// then the tap-config kegs map. Node.Alias mirrors Alias. +// then the tap-settings kegs map. Node.Alias mirrors Alias. // - RefQualified: Namespace+KegName set; the hub is implied from context. type NodeRef struct { Form RefForm diff --git a/pkg/keg/orientation.go b/pkg/keg/orientation.go new file mode 100644 index 00000000..043bcc06 --- /dev/null +++ b/pkg/keg/orientation.go @@ -0,0 +1,110 @@ +package keg + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" +) + +var ( + ErrOrientationStale = errors.New("orientation stale") + ErrOrientationDenied = errors.New("orientation denied") + ErrOrientationUnavailable = errors.New("orientation unavailable") + ErrOrientationRootUnavailable = errors.New("orientation root unavailable") +) + +// OrientationHeaderName carries trusted Tapper session state between Tapper's +// RemoteKeg client and a Hub. It is internal protocol state, never a model tool +// argument and never authorization by itself. +const OrientationHeaderName = "Tapper-Orientation" + +// OrientationState is the minimum state a Hub needs to recompute current +// authority for a governed request. +type OrientationState struct { + Root string `json:"root"` + Active string `json:"active"` + Revision string `json:"revision"` +} + +type orientationStateContextKey struct{} +type orientationValidatorContextKey struct{} + +// WithOrientationState binds trusted session orientation to an internal call +// context. RemoteKeg serializes it into OrientationHeaderName. +func WithOrientationState(ctx context.Context, state OrientationState) context.Context { + return context.WithValue(ctx, orientationStateContextKey{}, state) +} + +// OrientationStateFromContext returns trusted orientation state, when present. +func OrientationStateFromContext(ctx context.Context) (OrientationState, bool) { + state, ok := ctx.Value(orientationStateContextKey{}).(OrientationState) + return state, ok && state.Root != "" && state.Active != "" && state.Revision != "" +} + +// EncodeOrientationState returns the versioned header value. +func EncodeOrientationState(state OrientationState) (string, error) { + if state.Root == "" || state.Active == "" || state.Revision == "" { + return "", errors.New("orientation root, active flight, and revision are required") + } + raw, err := json.Marshal(state) + if err != nil { + return "", fmt.Errorf("encode orientation state: %w", err) + } + return "v1." + base64.RawURLEncoding.EncodeToString(raw), nil +} + +// DecodeOrientationState parses a versioned orientation header. The result is +// untrusted until the Hub authenticates the caller and recomputes Revision. +func DecodeOrientationState(value string) (OrientationState, error) { + value = strings.TrimSpace(value) + encoded, ok := strings.CutPrefix(value, "v1.") + if !ok || encoded == "" { + return OrientationState{}, errors.New("unsupported orientation header") + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return OrientationState{}, fmt.Errorf("decode orientation header: %w", err) + } + var state OrientationState + if err := json.Unmarshal(raw, &state); err != nil { + return OrientationState{}, fmt.Errorf("parse orientation header: %w", err) + } + if state.Root == "" || state.Active == "" || state.Revision == "" { + return OrientationState{}, errors.New("incomplete orientation header") + } + return state, nil +} + +// OrientationHeaderValue returns the header for trusted context state. +func OrientationHeaderValue(ctx context.Context) (string, bool) { + state, ok := OrientationStateFromContext(ctx) + if !ok { + return "", false + } + value, err := EncodeOrientationState(state) + return value, err == nil +} + +// OrientationValidator recomputes authority at an operation boundary. +type OrientationValidator func(context.Context) error + +// WithOrientationValidator installs the Hub-side validation callback used by +// durable mutation transactions after acquiring their locks. +func WithOrientationValidator(ctx context.Context, validate OrientationValidator) context.Context { + if validate == nil { + return ctx + } + return context.WithValue(ctx, orientationValidatorContextKey{}, validate) +} + +// ValidateOrientation runs the Hub-side validator, when one is installed. +func ValidateOrientation(ctx context.Context) error { + validate, _ := ctx.Value(orientationValidatorContextKey{}).(OrientationValidator) + if validate == nil { + return nil + } + return validate(ctx) +} diff --git a/pkg/keg/orientation_test.go b/pkg/keg/orientation_test.go new file mode 100644 index 00000000..2345687e --- /dev/null +++ b/pkg/keg/orientation_test.go @@ -0,0 +1,42 @@ +package keg + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type orientationRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f orientationRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestRemoteKegCarriesTrustedOrientationHeader(t *testing.T) { + state := OrientationState{Root: "@team/+root", Active: "@team/+child", Revision: "revision"} + ctx := WithOrientationState(context.Background(), state) + remote := NewRemoteKeg("https://hub.test/api/v1/@team/kegs/dev", "token", nil) + remote.client = &http.Client{Transport: orientationRoundTripFunc(func(req *http.Request) (*http.Response, error) { + decoded, err := DecodeOrientationState(req.Header.Get(OrientationHeaderName)) + if err != nil { + t.Fatalf("DecodeOrientationState: %v", err) + } + if decoded != state { + t.Fatalf("orientation header = %+v, want %+v", decoded, state) + } + return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil + })} + resp, err := remote.do(ctx, http.MethodGet, "/nodes", nil, "", http.Header{OrientationHeaderName: []string{"model-supplied"}}) + if err != nil { + t.Fatalf("remote do: %v", err) + } + _ = resp.Body.Close() +} + +func TestDecodeOrientationStateRejectsIncompleteAndUnknownVersions(t *testing.T) { + for _, raw := range []string{"", "v2.e30", "v1.e30", "v1.not-base64"} { + if _, err := DecodeOrientationState(raw); err == nil { + t.Fatalf("DecodeOrientationState(%q) succeeded", raw) + } + } +} diff --git a/pkg/keg/precondition.go b/pkg/keg/precondition.go new file mode 100644 index 00000000..959c01a2 --- /dev/null +++ b/pkg/keg/precondition.go @@ -0,0 +1,64 @@ +package keg + +import ( + "crypto/sha256" + "fmt" +) + +// DocumentHash returns the precondition token for a whole-document keg +// resource — a schema definition or the settings file. A caller echoes the +// token it read back on its next write, so a write is rejected when the +// document changed in between rather than silently overwriting the change. +// +// Nodes have their own token (NodeView.Hash) derived from content and +// metadata together; this is the equivalent for resources that are a single +// opaque YAML document. +// +// SHA-256 is deliberately fixed here rather than supplied by Runtime. These +// tokens cross local, browser, REST, and remote-client boundaries, so the same +// document must have the same token in every process. +func DocumentHash(data []byte) string { + if len(data) == 0 { + return "" + } + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +func requireExpectedHash(resource, expected string) error { + if expected != "" { + return nil + } + return fmt.Errorf("%s: %w", resource, ErrPreconditionRequired) +} + +func checkExpectedHash(resource, expected, current string, content []byte) error { + if err := requireExpectedHash(resource, expected); err != nil { + return err + } + if expected == current { + return nil + } + return &PreconditionConflictError{ + Resource: resource, + CurrentHash: current, + CurrentContent: append([]byte(nil), content...), + } +} + +func nodeRecoveryContent(view *NodeView) []byte { + if view == nil || len(view.Meta) == 0 { + if view == nil { + return nil + } + return append([]byte(nil), view.Content...) + } + out := make([]byte, 0, len(view.Meta)+len(view.Content)+10) + out = append(out, "---\n"...) + out = append(out, view.Meta...) + if out[len(out)-1] != '\n' { + out = append(out, '\n') + } + out = append(out, "---\n"...) + out = append(out, view.Content...) + return out +} diff --git a/pkg/keg/precondition_test.go b/pkg/keg/precondition_test.go new file mode 100644 index 00000000..6a750331 --- /dev/null +++ b/pkg/keg/precondition_test.go @@ -0,0 +1,201 @@ +package keg_test + +import ( + "crypto/sha256" + "errors" + "fmt" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func TestDocumentHashUsesFixedSHA256(t *testing.T) { + data := []byte("same document everywhere\n") + require.Equal(t, fmt.Sprintf("%x", sha256.Sum256(data)), keg.DocumentHash(data)) + require.Empty(t, keg.DocumentHash(nil)) +} + +func TestLocalKegNodePreconditionsProtectContentAndMetadataTogether(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("# Original\n"), Tags: []string{"before"}}) + require.NoError(t, err) + original, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + + _, err = k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: created.ID, Content: []byte("# Missing token\n")}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + unchanged, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, original.Content, unchanged.Content) + require.Equal(t, original.Meta, unchanged.Meta) + + metaResults, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ + ID: created.ID, Meta: []byte("tags: [after]\n"), HasMeta: true, ExpectedHash: original.Hash(), + }}) + require.NoError(t, err) + require.NotEqual(t, original.Hash(), metaResults[0].Hash) + + _, err = k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: created.ID, Content: []byte("# Stale content\n"), ExpectedHash: original.Hash()}) + require.Error(t, err) + require.ErrorIs(t, err, keg.ErrConflict) + var conflict *keg.PreconditionConflictError + require.True(t, errors.As(err, &conflict)) + current, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, current.Hash(), conflict.CurrentHash) + expectedRecovery := "---\n" + string(current.Meta) + if len(expectedRecovery) > 0 && expectedRecovery[len(expectedRecovery)-1] != '\n' { + expectedRecovery += "\n" + } + expectedRecovery += "---\n" + string(current.Content) + require.Equal(t, expectedRecovery, string(conflict.CurrentContent)) + require.Equal(t, "# Original\n", string(current.Content)) + + result, err := k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: created.ID, Content: []byte("# Current\n"), ExpectedHash: current.Hash()}) + require.NoError(t, err) + require.NotEmpty(t, result.Hash) + current, err = k.ReadNode(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "# Current\n", string(current.Content)) +} + +func TestLocalKegMoveAndRemoveRequireCurrentNodeHash(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + movable, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Movable\n")}) + require.NoError(t, err) + view, err := k.ReadNode(ctx, movable.ID) + require.NoError(t, err) + destination := keg.NodeId{ID: movable.ID.ID + 10} + + _, err = k.Move(ctx, keg.NodeMoveOptions{Source: movable.ID, Destination: destination}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + exists, err := k.NodeExists(ctx, movable.ID) + require.NoError(t, err) + require.True(t, exists) + + _, err = k.Move(ctx, keg.NodeMoveOptions{Source: movable.ID, Destination: destination, ExpectedHash: "stale"}) + require.ErrorIs(t, err, keg.ErrConflict) + exists, err = k.NodeExists(ctx, destination) + require.NoError(t, err) + require.False(t, exists) + + _, err = k.Move(ctx, keg.NodeMoveOptions{Source: movable.ID, Destination: destination, ExpectedHash: view.Hash()}) + require.NoError(t, err) + moved, err := k.ReadNode(ctx, destination) + require.NoError(t, err) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: destination}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: destination, ExpectedHash: "stale"}) + require.ErrorIs(t, err, keg.ErrConflict) + exists, err = k.NodeExists(ctx, destination) + require.NoError(t, err) + require.True(t, exists) + + _, err = k.Remove(ctx, keg.NodeRemoveOptions{ID: destination, ExpectedHash: moved.Hash()}) + require.NoError(t, err) + exists, err = k.NodeExists(ctx, destination) + require.NoError(t, err) + require.False(t, exists) +} + +func TestLocalKegSettingsPreconditionsUseExactPersistedDocument(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + repo := newTestMemoryRepo(fx.Runtime()) + k := keg.NewLocalKeg(repo, fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + + currentRaw := []byte("kegv: 2025-07\ntitle: Current\nsummary: |\n exact formatting\n") + require.NoError(t, repo.WriteSettingsDocument(ctx, currentRaw)) + current, err := k.Settings(ctx) + require.NoError(t, err) + require.Equal(t, currentRaw, current.Raw()) + + nextRaw := []byte("kegv: 2025-07\ntitle: Next\nsummary: changed\n") + err = k.SetSettings(ctx, nextRaw, keg.SettingsWriteOptions{}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + err = k.SetSettings(ctx, nextRaw, keg.SettingsWriteOptions{ExpectedHash: "stale"}) + require.ErrorIs(t, err, keg.ErrConflict) + var conflict *keg.PreconditionConflictError + require.True(t, errors.As(err, &conflict)) + require.Equal(t, keg.DocumentHash(currentRaw), conflict.CurrentHash) + require.Equal(t, currentRaw, conflict.CurrentContent) + stored, err := repo.ReadSettingsDocument(ctx) + require.NoError(t, err) + require.Equal(t, currentRaw, stored) + + require.NoError(t, k.SetSettings(ctx, nextRaw, keg.SettingsWriteOptions{ExpectedHash: current.Hash()})) + stored, err = repo.ReadSettingsDocument(ctx) + require.NoError(t, err) + require.Equal(t, nextRaw, stored) +} + +func TestLocalKegSchemaUpdateAndDeletePreconditionsUseStoredYAML(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + original := []byte("type: task\nsummary: |\n exact schema\n") + require.NoError(t, k.CreateSchema(ctx, "task", original)) + next := []byte("type: task\nsummary: updated\n") + + err := k.WriteSchema(ctx, "missing", []byte("type: missing\n"), keg.SchemaWriteOptions{ExpectedHash: "unused"}) + require.ErrorIs(t, err, keg.ErrNotExist, "WriteSchema is update-only; CreateSchema owns creation") + err = k.WriteSchema(ctx, "task", next, keg.SchemaWriteOptions{}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + err = k.WriteSchema(ctx, "task", next, keg.SchemaWriteOptions{ExpectedHash: "stale"}) + require.ErrorIs(t, err, keg.ErrConflict) + var conflict *keg.PreconditionConflictError + require.True(t, errors.As(err, &conflict)) + require.Equal(t, original, conflict.CurrentContent) + stored, err := k.ReadSchema(ctx, "task") + require.NoError(t, err) + require.Equal(t, original, stored) + + require.NoError(t, k.WriteSchema(ctx, "task", next, keg.SchemaWriteOptions{ExpectedHash: keg.DocumentHash(original)})) + err = k.DeleteSchema(ctx, "task", keg.SchemaWriteOptions{}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + err = k.DeleteSchema(ctx, "task", keg.SchemaWriteOptions{ExpectedHash: keg.DocumentHash(original)}) + require.ErrorIs(t, err, keg.ErrConflict) + stored, err = k.ReadSchema(ctx, "task") + require.NoError(t, err) + require.Equal(t, next, stored) + require.NoError(t, k.DeleteSchema(ctx, "task", keg.SchemaWriteOptions{ExpectedHash: keg.DocumentHash(next)})) + _, err = k.ReadSchema(ctx, "task") + require.ErrorIs(t, err, keg.ErrNotExist) +} + +func TestLocalKegQueryRemovalPinsHashesInsideWriteBoundary(t *testing.T) { + t.Parallel() + fx := NewSandbox(t) + ctx := fx.Context() + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) + initNonStrictTestKeg(t, k, ctx) + one, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# One\n"), Tags: []string{"discard"}}) + require.NoError(t, err) + two, err := k.Create(ctx, &keg.CreateOptions{Body: []byte("# Two\n"), Tags: []string{"keep"}}) + require.NoError(t, err) + + result, err := k.RemoveNodes(ctx, keg.RemoveNodesOptions{Query: "discard"}) + require.NoError(t, err) + require.Nil(t, result.Failure) + require.Equal(t, []keg.NodeId{one.ID}, []keg.NodeId{result.Removed[0].ID}) + exists, err := k.NodeExists(ctx, one.ID) + require.NoError(t, err) + require.False(t, exists) + exists, err = k.NodeExists(ctx, two.ID) + require.NoError(t, err) + require.True(t, exists) +} diff --git a/pkg/keg/remote_errors.go b/pkg/keg/remote_errors.go index 5cefa99f..f4f0f729 100644 --- a/pkg/keg/remote_errors.go +++ b/pkg/keg/remote_errors.go @@ -11,22 +11,27 @@ import ( // when writing a response, and RemoteKeg maps (code, status) back to the // sentinel when decoding one. Keep the two sides symmetric. const ( - RemoteCodeNotFound = "NOT_FOUND" - RemoteCodeExist = "EXIST" - RemoteCodeDestExists = "DEST_EXISTS" - RemoteCodeConflict = "CONFLICT" - RemoteCodeInvalid = "INVALID" - RemoteCodeSchemaInvalid = "SCHEMA_INVALID" - RemoteCodeInvalidImage = "INVALID_IMAGE" - RemoteCodeLockMismatch = "LOCK_MISMATCH" - RemoteCodeNotLocked = "NOT_LOCKED" - RemoteCodeLock = "LOCK" - RemoteCodeLockTimeout = "LOCK_TIMEOUT" - RemoteCodeNotSupported = "NOT_SUPPORTED" - RemoteCodeUnauthorized = "UNAUTHORIZED" - RemoteCodeForbidden = "FORBIDDEN" - RemoteCodeBadRequest = "BAD_REQUEST" - RemoteCodeInternal = "INTERNAL" + RemoteCodeNotFound = "NOT_FOUND" + RemoteCodeExist = "EXIST" + RemoteCodeDestExists = "DEST_EXISTS" + RemoteCodeConflict = "CONFLICT" + RemoteCodePreconditionRequired = "PRECONDITION_REQUIRED" + RemoteCodeInvalid = "INVALID" + RemoteCodeSchemaInvalid = "SCHEMA_INVALID" + RemoteCodeInvalidImage = "INVALID_IMAGE" + RemoteCodeLockMismatch = "LOCK_MISMATCH" + RemoteCodeNotLocked = "NOT_LOCKED" + RemoteCodeLock = "LOCK" + RemoteCodeLockTimeout = "LOCK_TIMEOUT" + RemoteCodeNotSupported = "NOT_SUPPORTED" + RemoteCodeUnauthorized = "UNAUTHORIZED" + RemoteCodeForbidden = "FORBIDDEN" + RemoteCodeBadRequest = "BAD_REQUEST" + RemoteCodeOrientationStale = "ORIENTATION_STALE" + RemoteCodeOrientationDenied = "ORIENTATION_DENIED" + RemoteCodeOrientationUnavailable = "ORIENTATION_UNAVAILABLE" + RemoteCodeOrientationRootUnavailable = "ORIENTATION_ROOT_UNAVAILABLE" + RemoteCodeInternal = "INTERNAL" ) // remoteCodeTable pairs each sentinel with its wire code and HTTP status. @@ -38,6 +43,7 @@ var remoteCodeTable = []struct { {ErrNotExist, RemoteCodeNotFound, http.StatusNotFound}, {ErrDestinationExists, RemoteCodeDestExists, http.StatusConflict}, {ErrExist, RemoteCodeExist, http.StatusConflict}, + {ErrPreconditionRequired, RemoteCodePreconditionRequired, http.StatusPreconditionRequired}, {ErrConflict, RemoteCodeConflict, http.StatusConflict}, {ErrSchemaInvalid, RemoteCodeSchemaInvalid, http.StatusBadRequest}, {ErrInvalidImage, RemoteCodeInvalidImage, http.StatusBadRequest}, @@ -47,11 +53,19 @@ var remoteCodeTable = []struct { {ErrLockTimeout, RemoteCodeLockTimeout, http.StatusConflict}, {ErrLock, RemoteCodeLock, http.StatusConflict}, {ErrNotSupported, RemoteCodeNotSupported, http.StatusNotImplemented}, + {ErrOrientationStale, RemoteCodeOrientationStale, http.StatusConflict}, + {ErrOrientationDenied, RemoteCodeOrientationDenied, http.StatusForbidden}, + {ErrOrientationUnavailable, RemoteCodeOrientationUnavailable, http.StatusServiceUnavailable}, + {ErrOrientationRootUnavailable, RemoteCodeOrientationRootUnavailable, http.StatusGone}, } // RemoteErrorCode maps err to its wire (code, status). Unrecognized errors // map to (INTERNAL, 500). func RemoteErrorCode(err error) (code string, status int) { + var conflict *PreconditionConflictError + if errors.As(err, &conflict) { + return RemoteCodeConflict, http.StatusPreconditionFailed + } for _, entry := range remoteCodeTable { if errors.Is(err, entry.err) { return entry.code, entry.status diff --git a/pkg/keg/remote_precondition_test.go b/pkg/keg/remote_precondition_test.go new file mode 100644 index 00000000..cb855306 --- /dev/null +++ b/pkg/keg/remote_precondition_test.go @@ -0,0 +1,65 @@ +package keg_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/stretchr/testify/require" +) + +func TestRemoteKegDocumentWritesSendIfMatch(t *testing.T) { + t.Parallel() + wants := map[string]string{ + "PUT /settings": "settings-hash", + "PUT /schemas/task": "schema-write-hash", + "DELETE /schemas/task": "schema-delete-hash", + } + seen := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen[r.Method+" "+r.URL.Path] = r.Header.Get("If-Match") + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + ctx := context.Background() + + require.NoError(t, rk.SetSettings(ctx, []byte("kegv: 2025-07\n"), keg.SettingsWriteOptions{ExpectedHash: wants["PUT /settings"]})) + require.NoError(t, rk.WriteSchema(ctx, "task", []byte("type: task\n"), keg.SchemaWriteOptions{ExpectedHash: wants["PUT /schemas/task"]})) + require.NoError(t, rk.DeleteSchema(ctx, "task", keg.SchemaWriteOptions{ExpectedHash: wants["DELETE /schemas/task"]})) + require.Equal(t, wants, seen) +} + +func TestRemoteKegDecodesPreconditionErrorsWithRecoveryFields(t *testing.T) { + t.Parallel() + t.Run("required", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionRequired) + _, _ = w.Write([]byte(`{"error":"If-Match is required","code":"PRECONDITION_REQUIRED","operationPerformed":false}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + err := rk.SetSettings(context.Background(), []byte("kegv: 2025-07\n"), keg.SettingsWriteOptions{}) + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + }) + + t.Run("conflict", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = w.Write([]byte(`{"error":"stale","code":"CONFLICT","operationPerformed":false,"currentHash":"fresh","currentContent":"type: task\n"}`)) + })) + t.Cleanup(srv.Close) + rk := keg.NewRemoteKeg(srv.URL, "", nil) + err := rk.WriteSchema(context.Background(), "task", []byte("type: task\nsummary: stale\n"), keg.SchemaWriteOptions{ExpectedHash: "stale"}) + require.ErrorIs(t, err, keg.ErrConflict) + var conflict *keg.PreconditionConflictError + require.True(t, errors.As(err, &conflict)) + require.Equal(t, "fresh", conflict.CurrentHash) + require.Equal(t, "type: task\n", string(conflict.CurrentContent)) + }) +} diff --git a/pkg/keg/render.go b/pkg/keg/render.go index 8af5c98f..30280e36 100644 --- a/pkg/keg/render.go +++ b/pkg/keg/render.go @@ -135,8 +135,8 @@ func ResolveNodeLink(dest string, opts RenderOptions) (string, bool) { return newDest + strings.TrimSpace(m[2]), true } - // Resolve relative to the node's content file, mirroring how the link - // resolves on disk where the page is //README.md. + // Resolve relative to the node's logical content path. This preserves the + // established ../NODEID link semantics without requiring local storage. base, err := url.Parse(baseURL + opts.NodeID + "/README.md") if err != nil { return "", false diff --git a/pkg/keg/render_test.go b/pkg/keg/render_test.go index 261f29ea..53d830cd 100644 --- a/pkg/keg/render_test.go +++ b/pkg/keg/render_test.go @@ -193,6 +193,25 @@ func TestRenderMarkdown_hubStyleResolution(t *testing.T) { require.NotContains(t, out, `href="/@foldwise/example/9"`) } +func TestRenderMarkdown_BareKegReferenceRemainsPlainText(t *testing.T) { + opts := keg.RenderOptions{ + BaseURL: "/@foldwise/example/", + NodeID: "2", + NoTrailingSlash: true, + KegResolver: func(ns, alias, id string) string { + return "/@foldwise/" + alias + "/" + id + }, + } + html, err := keg.RenderMarkdown([]byte( + "Bare keg:public/7 stays prose; [linked](keg:public/8) is a graph link.", + ), opts) + require.NoError(t, err) + out := string(html) + require.Contains(t, out, "Bare keg:public/7 stays prose") + require.NotContains(t, out, `href="/@foldwise/public/7"`) + require.Contains(t, out, `href="/@foldwise/public/8"`) +} + func TestRenderMarkdown_autolinkUntouched(t *testing.T) { src := "Visit https://example.com/page now." html, err := keg.RenderMarkdown([]byte(src), keg.RenderOptions{NodeID: "2"}) diff --git a/pkg/keg/repo_atomic.go b/pkg/keg/repo_atomic.go deleted file mode 100644 index 46116cbb..00000000 --- a/pkg/keg/repo_atomic.go +++ /dev/null @@ -1,201 +0,0 @@ -package keg - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" -) - -type memoryRepoState struct { - nodes map[NodeId]*memoryNode - indexes map[string][]byte - schemas map[string][]byte - snapshots map[NodeId][]memorySnapshotEntry - config *Config -} - -func (r *MemoryRepo) WithKegAtomicWrite(ctx context.Context, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - return r.WithKegWrite(ctx, func(writeCtx context.Context) error { - state, err := r.cloneState(writeCtx) - if err != nil { - return err - } - if err := fn(writeCtx); err != nil { - r.restoreState(state) - return err - } - return nil - }) -} - -func (r *MemoryRepo) cloneState(ctx context.Context) (*memoryRepoState, error) { - r.mu.RLock() - defer r.mu.RUnlock() - state := &memoryRepoState{ - nodes: make(map[NodeId]*memoryNode, len(r.nodes)), indexes: make(map[string][]byte, len(r.indexes)), - schemas: make(map[string][]byte, len(r.schemas)), snapshots: make(map[NodeId][]memorySnapshotEntry, len(r.snapshots)), - } - for id, n := range r.nodes { - copyNode := &memoryNode{content: cloneBytes(n.content), meta: cloneBytes(n.meta), stats: cloneBytes(n.stats), items: map[string][]byte{}, images: map[string][]byte{}} - for name, data := range n.items { - copyNode.items[name] = cloneBytes(data) - } - for name, data := range n.images { - copyNode.images[name] = cloneBytes(data) - } - state.nodes[id] = copyNode - } - for name, data := range r.indexes { - state.indexes[name] = cloneBytes(data) - } - for name, data := range r.schemas { - state.schemas[name] = cloneBytes(data) - } - for id, entries := range r.snapshots { - cloned := make([]memorySnapshotEntry, len(entries)) - for i, entry := range entries { - cloned[i] = entry - cloned[i].content = cloneBytes(entry.content) - cloned[i].meta = cloneBytes(entry.meta) - cloned[i].stats = cloneBytes(entry.stats) - } - state.snapshots[id] = cloned - } - if r.config != nil { - raw, err := json.Marshal(r.config) - if err != nil { - return nil, err - } - var cfg Config - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, err - } - state.config = &cfg - } - _ = ctx - return state, nil -} - -func (r *MemoryRepo) restoreState(state *memoryRepoState) { - if state == nil { - return - } - r.mu.Lock() - r.nodes, r.indexes, r.schemas, r.snapshots, r.config = state.nodes, state.indexes, state.schemas, state.snapshots, state.config - r.mu.Unlock() -} - -type fsBackupEntry struct { - dir bool - mode os.FileMode - data []byte -} - -func (f *FsRepo) WithKegAtomicWrite(ctx context.Context, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - return f.WithKegWrite(ctx, func(writeCtx context.Context) error { - backup, err := f.captureRoot() - if err != nil { - return err - } - if err := fn(writeCtx); err != nil { - return errors.Join(err, f.restoreRoot(backup)) - } - return nil - }) -} - -func (f *FsRepo) captureRoot() (map[string]fsBackupEntry, error) { - out := map[string]fsBackupEntry{".": {dir: true, mode: 0o755}} - var walk func(string) error - walk = func(rel string) error { - path := f.Root - if rel != "." { - path = filepath.Join(f.Root, rel) - } - entries, err := f.runtime.ReadDir(path) - if err != nil { - return err - } - for _, entry := range entries { - child := entry.Name() - if rel != "." { - child = filepath.Join(rel, child) - } - if child == KegOperationLock { - continue - } - info, err := entry.Info() - if err != nil { - return err - } - if info.IsDir() { - out[child] = fsBackupEntry{dir: true, mode: info.Mode().Perm()} - if err := walk(child); err != nil { - return err - } - continue - } - if !info.Mode().IsRegular() { - return fmt.Errorf("atomic KEG write does not support %s", child) - } - data, err := f.runtime.ReadFile(filepath.Join(f.Root, child)) - if err != nil { - return err - } - out[child] = fsBackupEntry{mode: info.Mode().Perm(), data: data} - } - return nil - } - return out, walk(".") -} - -func (f *FsRepo) restoreRoot(backup map[string]fsBackupEntry) error { - current, err := f.captureRoot() - if err != nil { - return err - } - paths := make([]string, 0, len(current)) - for path := range current { - if path != "." { - paths = append(paths, path) - } - } - sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) - var errs []error - for _, rel := range paths { - if _, keep := backup[rel]; keep { - continue - } - errs = append(errs, f.runtime.Remove(filepath.Join(f.Root, rel), true)) - } - paths = paths[:0] - for path := range backup { - if path != "." { - paths = append(paths, path) - } - } - sort.Slice(paths, func(i, j int) bool { return len(paths[i]) < len(paths[j]) }) - for _, rel := range paths { - entry := backup[rel] - path := filepath.Join(f.Root, rel) - if entry.dir { - errs = append(errs, f.runtime.Mkdir(path, entry.mode, true)) - continue - } - errs = append(errs, f.runtime.WriteFile(path, entry.data, entry.mode)) - } - return errors.Join(errs...) -} - -var _ RepositoryAtomicWrite = (*MemoryRepo)(nil) -var _ RepositoryAtomicWrite = (*FsRepo)(nil) diff --git a/pkg/keg/repo_events.go b/pkg/keg/repo_events.go index af1d79c1..1f788a38 100644 --- a/pkg/keg/repo_events.go +++ b/pkg/keg/repo_events.go @@ -57,7 +57,6 @@ type RepositoryEvents interface { // resources when ctx is canceled. Watch(ctx context.Context, ids ...NodeId) (<-chan NodeEvent, error) // Emit sends a NodeEvent to all active subscribers whose filters match. - // This is used for programmatic events (e.g. access tracking) that - // cannot be detected by filesystem watchers. + // Repositories use it for programmatic events such as access tracking. Emit(ev NodeEvent) } diff --git a/pkg/keg/repo_events_test.go b/pkg/keg/repo_events_test.go deleted file mode 100644 index 099decbc..00000000 --- a/pkg/keg/repo_events_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package keg_test - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -// ---------- MemoryRepo event tests ---------- - -func TestMemoryRepoEvents_WatchReceivesEmittedEvents(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx, cancel := context.WithTimeout(fx.Context(), 2*time.Second) - defer cancel() - - ch, err := repo.Watch(ctx, keg.NodeId{ID: 5}) - require.NoError(t, err) - - expected := keg.NodeEvent{ - Kind: keg.NodeEventModified, - NodeID: keg.NodeId{ID: 5}, - Field: "content", - } - repo.Emit(expected) - - select { - case got := <-ch: - require.Equal(t, expected.Kind, got.Kind) - require.Equal(t, expected.NodeID, got.NodeID) - require.Equal(t, expected.Field, got.Field) - case <-ctx.Done(): - t.Fatal("timed out waiting for event") - } -} - -func TestMemoryRepoEvents_FilterByNodeID(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx, cancel := context.WithTimeout(fx.Context(), 2*time.Second) - defer cancel() - - // Watch only node 3. - ch, err := repo.Watch(ctx, keg.NodeId{ID: 3}) - require.NoError(t, err) - - // Emit event for node 7 — should not be received. - repo.Emit(keg.NodeEvent{ - Kind: keg.NodeEventCreated, - NodeID: keg.NodeId{ID: 7}, - Field: "content", - }) - // Emit event for node 3 — should be received. - repo.Emit(keg.NodeEvent{ - Kind: keg.NodeEventModified, - NodeID: keg.NodeId{ID: 3}, - Field: "meta", - }) - - select { - case got := <-ch: - require.Equal(t, keg.NodeId{ID: 3}, got.NodeID) - require.Equal(t, "meta", got.Field) - case <-ctx.Done(): - t.Fatal("timed out waiting for filtered event") - } -} - -func TestMemoryRepoEvents_WatchAllNodes(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx, cancel := context.WithTimeout(fx.Context(), 2*time.Second) - defer cancel() - - // Watch all nodes (no IDs specified). - ch, err := repo.Watch(ctx) - require.NoError(t, err) - - repo.Emit(keg.NodeEvent{ - Kind: keg.NodeEventCreated, - NodeID: keg.NodeId{ID: 42}, - Field: "content", - }) - - select { - case got := <-ch: - require.Equal(t, keg.NodeId{ID: 42}, got.NodeID) - case <-ctx.Done(): - t.Fatal("timed out waiting for all-node event") - } -} - -func TestMemoryRepoEvents_ContextCancellation(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx, cancel := context.WithCancel(fx.Context()) - ch, err := repo.Watch(ctx) - require.NoError(t, err) - - cancel() - - // Channel should be closed after context cancellation. - select { - case _, ok := <-ch: - require.False(t, ok, "channel should be closed after cancel") - case <-time.After(500 * time.Millisecond): - t.Fatal("channel was not closed after cancel") - } -} - -func TestMemoryRepoEvents_CancelledWatchStopsDelivery(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx, cancel := context.WithCancel(fx.Context()) - ch, err := repo.Watch(ctx, keg.NodeId{ID: 1}) - require.NoError(t, err) - - cancel() - - // Drain until close, then Emit must not panic (subscriber is gone). - deadline := time.After(time.Second) - for { - select { - case _, ok := <-ch: - if !ok { - repo.Emit(keg.NodeEvent{ - Kind: keg.NodeEventModified, - NodeID: keg.NodeId{ID: 1}, - Field: "content", - }) - return - } - case <-deadline: - t.Fatal("channel was not closed after cancel") - } - } -} - -func TestMemoryRepoEvents_ReadContentEmitsAccessed(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - repo := keg.NewMemoryRepo(fx.Runtime()) - - ctx := fx.Context() - id := keg.NodeId{ID: 1} - require.NoError(t, repo.WriteContent(ctx, id, []byte("# Hello\n"))) - - watchCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - - ch, err := repo.Watch(watchCtx, id) - require.NoError(t, err) - - // Reading content should emit an accessed event. - _, readErr := repo.ReadContent(ctx, id) - require.NoError(t, readErr) - - select { - case got := <-ch: - require.Equal(t, keg.NodeEventAccessed, got.Kind) - require.Equal(t, id, got.NodeID) - require.Equal(t, "content", got.Field) - case <-watchCtx.Done(): - t.Fatal("timed out waiting for accessed event") - } -} - -// ---------- FsRepo event tests ---------- - -func TestFsRepoEvents_WatchDetectsContentChange(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, sandbox.WithFixture("example", "~/testrepo")) - ctx, cancel := context.WithTimeout(fx.Context(), 5*time.Second) - defer cancel() - - repo := keg.NewFsRepo("~/testrepo", fx.Runtime()) - - id := keg.NodeId{ID: 0} - ch, watchErr := repo.Watch(ctx, id) - require.NoError(t, watchErr) - - // Modify the content file on disk using the real filesystem path. - // ResolvePath(false) returns the virtual path; apply jail prefix to get - // the real OS path that fsnotify watches. - virtualRoot, err2 := fx.Runtime().ResolvePath("~/testrepo", false) - require.NoError(t, err2) - jail := fx.Runtime().GetJail() - realRoot := filepath.Join(jail, strings.TrimPrefix(virtualRoot, string(filepath.Separator))) - contentPath := filepath.Join(realRoot, id.Path(), "README.md") - require.NoError(t, os.WriteFile(contentPath, []byte("# updated content\n"), 0o644)) - - // Wait for a debounced event. - select { - case ev := <-ch: - require.Equal(t, id, ev.NodeID) - require.Equal(t, "content", ev.Field) - case <-ctx.Done(): - t.Fatal("timed out waiting for fs content change event") - } -} - -func TestFsRepoEvents_CancelStopsWatcher(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, sandbox.WithFixture("example", "~/testrepo")) - - repo := keg.NewFsRepo("~/testrepo", fx.Runtime()) - - ctx, cancel := context.WithCancel(fx.Context()) - ch, watchErr := repo.Watch(ctx, keg.NodeId{ID: 0}) - require.NoError(t, watchErr) - - cancel() - - // Channel should eventually close. - select { - case _, ok := <-ch: - require.False(t, ok, "channel should be closed after cancel") - case <-time.After(1 * time.Second): - t.Fatal("channel was not closed after cancel") - } -} - -// ---------- NodeEventKind.String test ---------- - -func TestNodeEventKind_String(t *testing.T) { - t.Parallel() - require.Equal(t, "created", keg.NodeEventCreated.String()) - require.Equal(t, "modified", keg.NodeEventModified.String()) - require.Equal(t, "deleted", keg.NodeEventDeleted.String()) - require.Equal(t, "accessed", keg.NodeEventAccessed.String()) - require.Equal(t, "unknown", keg.NodeEventKind(0).String()) -} diff --git a/pkg/keg/repo_filesystem.go b/pkg/keg/repo_filesystem.go deleted file mode 100644 index f3ca1253..00000000 --- a/pkg/keg/repo_filesystem.go +++ /dev/null @@ -1,1147 +0,0 @@ -package keg - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "sync" - "syscall" - "time" - - appCtx "github.com/jlrickert/cli-toolkit/appctx" - "github.com/jlrickert/cli-toolkit/toolkit" -) - -const ( - MarkdownContentFilename = "README.md" - YAMLMetaFilename = "meta.yaml" - JSONStatsFilename = "stats.json" - KegCurrentEnvKey = "KEG_CURRENT" - KegLockFile = ".keg-lock" - NodeImagesDir = "images" - NodeAttachmentsDir = "assets" -) - -// FsRepo implements [Repository] using the local filesystem as storage. It -// manages KEG nodes as directories under [Root], with each node containing -// content files, metadata, and optional attachments. Thread-safe operations -// are coordinated through the embedded mutex. -type FsRepo struct { - // Root is the base directory path containing all KEG node directories - Root string - // ContentFilename specifies the filename for node content (typically README.md) - ContentFilename string - // MetaFilename specifies the filename for node metadata (typically meta.yaml) - MetaFilename string - StatsFilename string - // SnapshotCheckpointInterval controls how many patch revisions may occur - // after a checkpoint before the next snapshot is stored as a full blob. - SnapshotCheckpointInterval int - - runtime *toolkit.Runtime - - // watchersMu guards the watchers slice for access event emission. - watchersMu sync.Mutex - watchers []*fsWatch -} - -// NewFsRepo constructs a filesystem repository with the provided root/runtime. -func NewFsRepo(root string, rt *toolkit.Runtime) *FsRepo { - return &FsRepo{ - Root: root, - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - SnapshotCheckpointInterval: defaultSnapshotCheckpointInterval, - runtime: rt, - } -} - -// ------------------------------- constructors -------------------------------- - -// NewFsRepoFromEnvOrSearch tries to locate a keg file using the order: -// 1) KEG_CURRENT env var (file or directory) -// 2) current working directory -// 3) if inside a git project, search the project tree for a keg file -// 4) recursive search from current working directory -// 5) fallback to default config location (~/.config/keg or XDG equivalent) -// -// Returns a pointer to an initialized FsRepo and the path of the discovered keg -// file (or "" if using fallback path). -func NewFsRepoFromEnvOrSearch(ctx context.Context, rt *toolkit.Runtime) (*FsRepo, error) { - // candidate names we consider a keg file - candidates := []string{"keg", "keg.yaml", "keg.yml"} - - // 1) KEG_CURRENT - if v := rt.Get(KegCurrentEnvKey); v != "" { - if p, err := resolveKegFromEnv(ctx, rt, v, candidates); err == nil { - f := &FsRepo{ - Root: p.rootDir, - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - return f, nil - } - // if env set but didn't resolve, continue searching (do not fail) - } - - // 2) current directory - cwd, err := rt.Getwd() - if err != nil { - return nil, NewBackendError("fs", - "NewFsRepoFromEnvOrSearch", 0, err, false) - } - if kp := findKegInDir(ctx, rt, cwd, candidates); kp != "" { - f := &FsRepo{ - Root: cwd, - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - return f, nil - } - - // 3) if in a git project, find git root and search the project tree - if gitRoot := appCtx.FindGitRoot(ctx, rt, cwd); gitRoot != "" { - if kp := findKegRecursive(gitRoot, candidates); kp != "" { - f := &FsRepo{ - Root: filepath.Dir(kp), // directory containing the keg file - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - return f, nil - } - } - - // 4) traverse current directory recursively (in case the keg is somewhere - // under cwd) - if kp := findKegRecursive(cwd, candidates); kp != "" { - f := &FsRepo{ - Root: filepath.Dir(kp), - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - return f, nil - } - - // 5) fallback default: use XDG config dir or $HOME/.config/keg - cfgDir, cfgErr := toolkit.UserConfigPath(rt) - if cfgErr == nil { - defDir := filepath.Join(cfgDir, "keg") - // create directory if missing? only choose as root, don't create file. - f := &FsRepo{ - Root: defDir, - ContentFilename: MarkdownContentFilename, - MetaFilename: YAMLMetaFilename, - StatsFilename: JSONStatsFilename, - runtime: rt, - } - return f, nil - } - - return nil, NewBackendError( - "fs", - "NewFsRepoFromEnvOrSearch", - 0, - fmt.Errorf("could not determine fallback config dir: %w", cfgErr), - false, - ) -} - -// helper types for env resolution -type envResolveResult struct { - rootDir string // directory for the repo root that will contain keg file - kegPath string // full path to the keg file (may be empty if not present) -} - -// resolveKegFromEnv accepts KEG_CURRENT value which can be: -// - absolute path to a file (keg file) -> use its directory as root -// - directory path -> check for a keg file inside that directory -> if found -// use that -// -// if nothing matches, returns error. -// -// This refactor uses std helpers to expand env vars and tildes. ctx may be nil. -func resolveKegFromEnv(ctx context.Context, rt *toolkit.Runtime, v string, candidates []string) (envResolveResult, error) { - - // Expand env vars first, then attempt path expansion. - v = toolkit.ExpandEnv(rt, v) - if expanded, err := toolkit.ExpandPath(rt, v); err == nil { - v = expanded - } - info, err := rt.Stat(v, false) - if err == nil && info.Mode().IsRegular() { - // env pointed to a file; verify its name is a candidate - base := filepath.Base(v) - if slices.Contains(candidates, base) { - return envResolveResult{rootDir: filepath.Dir(v), kegPath: v}, nil - } - return envResolveResult{}, NewBackendError("fs", - "resolveKegFromEnv", 0, - errors.New("KEG_CURRENT pointed to a file that is not a known keg filename"), - false) - } - if err == nil && info.IsDir() { - // env pointed to a directory: check for candidate file inside - for _, c := range candidates { - p := filepath.Join(v, c) - if fi, statErr := rt.Stat(p, false); statErr == nil && fi.Mode().IsRegular() { - return envResolveResult{rootDir: v, kegPath: p}, nil - } - } - // directory but no keg file found — treat as valid root only if caller - // expects that. For our purposes require the keg file to exist; return - // error to let caller continue search. - return envResolveResult{}, NewBackendError("fs", - "resolveKegFromEnv", 0, - errors.New("KEG_CURRENT directory does not contain a keg file"), - false) - } - // path doesn't exist or stat failed — treat as error - return envResolveResult{}, NewBackendError("fs", - "resolveKegFromEnv", 0, err, false) -} - -// findKegInDir checks if any candidate keg filename exists directly in dir. -// returns full path or "". -func findKegInDir(ctx context.Context, rt *toolkit.Runtime, dir string, candidates []string) string { - for _, c := range candidates { - p := filepath.Join(dir, c) - if fi, err := rt.Stat(p, false); err == nil && fi.Mode().IsRegular() { - return p - } - } - return "" -} - -// findKegRecursive walks root and returns the first matched keg file path, or -// "" if none. -func findKegRecursive(root string, candidates []string) string { - // use WalkDir for efficiency; stop early on first found. - var found string - filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.Type().IsRegular() { - base := filepath.Base(path) - if slices.Contains(candidates, base) { - found = path - return filepath.SkipAll - } - } - return nil - }) - return found -} - -// ------------------ Repository interface implementation ------------------ - -func (f *FsRepo) Name() string { - return "fs" -} - -func (f *FsRepo) HasNode(ctx context.Context, id NodeId) (bool, error) { - _ = ctx - nodeDir := filepath.Join(f.Root, id.Path()) - info, err := f.runtime.Stat(nodeDir, false) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, NewBackendError(f.Name(), "HasNode", 0, err, false) - } - return info.IsDir(), nil -} - -func (f *FsRepo) Runtime() *toolkit.Runtime { - if f == nil { - return nil - } - return f.runtime -} - -// lockInfo is the JSON structure written into lock files for process-aware -// stale lock detection. -type lockInfo struct { - PID int `json:"pid"` - Hostname string `json:"hostname"` - StartedAt string `json:"started_at"` - UID string `json:"uid"` -} - -// isLockStale reads a lock file and checks whether the owning process is still -// alive. Returns true if the lock is definitely stale (process dead or file -// unreadable/corrupt), false otherwise. -func (f *FsRepo) isLockStale(lockPath string) bool { - data, err := f.runtime.ReadFile(lockPath) - if err != nil { - return false - } - var info lockInfo - if json.Unmarshal(data, &info) != nil { - // Corrupt lock file — treat as stale. - return true - } - if info.PID <= 0 { - return true - } - proc, err := os.FindProcess(info.PID) - if err != nil { - return true - } - // Signal 0 checks existence without killing. - if err := proc.Signal(syscall.Signal(0)); err != nil { - return true // process is dead - } - return false -} - -// writeLockMetadata writes process identity JSON into the lock directory. -func (f *FsRepo) writeLockMetadata(lockPath string) { - pi := f.runtime.Process() - if pi == nil { - return - } - info := lockInfo{ - PID: pi.PID, - Hostname: pi.Hostname, - StartedAt: pi.StartedAt.Format(time.RFC3339Nano), - UID: pi.UID, - } - data, err := json.Marshal(info) - if err != nil { - return - } - metaPath := filepath.Join(lockPath, "owner.json") - _ = f.runtime.WriteFile(metaPath, data, 0o644) -} - -// lockMetadataPath returns the path to the owner metadata file inside the lock -// directory. -func lockMetadataPath(lockPath string) string { - return filepath.Join(lockPath, "owner.json") -} - -// WithNodeLock executes fn while holding an exclusive lock for node id. -// The lock uses atomic mkdir with optional process metadata for stale lock -// detection. When process info is available (via runtime.Process()), a JSON -// metadata file is written inside the lock directory. If the lock directory -// already exists and the owning process is dead, the stale lock is removed and -// acquisition is retried. -func (f *FsRepo) WithNodeLock(ctx context.Context, id NodeId, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - if contextHasNodeLock(ctx, id) { - return fn(ctx) - } - - nodeDir := filepath.Join(f.Root, id.Path()) - - // Track whether the node directory pre-existed so we can clean up bare - // directories created solely as a lock artifact. - _, preStat := f.runtime.Stat(nodeDir, false) - dirExistedBefore := preStat == nil - - if err := f.runtime.Mkdir(nodeDir, 0o755, true); err != nil { - return errors.Join(ErrLock, NewBackendError(f.Name(), "WithNodeLock", 0, err, false)) - } - - lockPath := filepath.Join(nodeDir, KegLockFile) - for { - err := f.runtime.Mkdir(lockPath, 0o700, false) - if err == nil { - // Lock acquired — write process metadata if available. - f.writeLockMetadata(lockPath) - break - } - if os.IsExist(err) { - // Check for stale lock when process info is available. - metaFile := lockMetadataPath(lockPath) - if f.isLockStale(metaFile) { - // Remove the stale lock and retry immediately. - _ = f.runtime.Remove(lockPath, true) - continue - } - select { - case <-ctx.Done(): - return fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-time.After(100 * time.Millisecond): - } - continue - } - return errors.Join(ErrLock, NewBackendError(f.Name(), "WithNodeLock", 0, err, false)) - } - - lockedCtx := contextWithNodeLock(ctx, id) - runErr := fn(lockedCtx) - - unlockErr := f.runtime.Remove(lockPath, true) - if unlockErr != nil && !os.IsNotExist(unlockErr) { - unlockErr = errors.Join(ErrLock, NewBackendError(f.Name(), "WithNodeLockUnlock", 0, unlockErr, false)) - } else { - unlockErr = nil - } - - // Clean up bare directories created solely as a lock artifact. If the - // node directory did not exist before locking and now contains no content - // file, remove it to prevent HasNode/ListNodes false positives. - if !dirExistedBefore { - contentPath := filepath.Join(nodeDir, f.ContentFilename) - if _, statErr := f.runtime.Stat(contentPath, false); statErr != nil && os.IsNotExist(statErr) { - _ = f.runtime.Remove(nodeDir, true) - } - } - - return errors.Join(runErr, unlockErr) -} - -func (f *FsRepo) Next(ctx context.Context) (NodeId, error) { - // Ensure repo root exists (if not, create it) - if _, statErr := f.runtime.Stat(f.Root, false); statErr != nil { - return NodeId{}, NewBackendError(f.Name(), "Next", 0, statErr, false) - } - - for { - entries, err := f.runtime.ReadDir(f.Root) - if err != nil { - return NodeId{}, NewBackendError(f.Name(), "Next", 0, err, false) - } - - maxID := -1 - for _, e := range entries { - if !e.IsDir() { - continue - } - if n, perr := ParseNode(e.Name()); perr == nil && n != nil { - if n.ID > maxID { - maxID = n.ID - } - } - } - - candidate := maxID + 1 - nodeDir := filepath.Join(f.Root, NodeId{ID: candidate}.Path()) - // Atomic mkdir — if another process created this directory between - // our ReadDir and Mkdir, we get EEXIST and retry with a fresh scan. - err = f.runtime.Mkdir(nodeDir, 0o755, false) - if err == nil { - return NodeId{ID: candidate}, nil - } - if os.IsExist(err) { - continue // retry with fresh scan - } - return NodeId{}, NewBackendError(f.Name(), "Next", 0, err, false) - } -} - -// ReadContent implements Repository. -func (f *FsRepo) ReadContent(ctx context.Context, id NodeId) ([]byte, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - nodeDir := filepath.Join(f.Root, id.Path()) - contentPath := filepath.Join(nodeDir, f.ContentFilename) - b, err := f.runtime.ReadFile(contentPath) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadContent", 0, err, false) - } - f.emitToWatchers(NodeEvent{Kind: NodeEventAccessed, NodeID: id, Field: "content"}) - return b, nil -} - -// ReadMeta implements Repository. -func (f *FsRepo) ReadMeta(ctx context.Context, id NodeId) ([]byte, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - nodeDir := filepath.Join(f.Root, id.Path()) - metaPath := filepath.Join(nodeDir, f.MetaFilename) - b, err := f.runtime.ReadFile(metaPath) - if err != nil { - if os.IsNotExist(err) { - return []byte(nil), nil - } - return nil, NewBackendError(f.Name(), "ReadMeta", 0, err, false) - } - return b, nil -} - -// ReadStats implements Repository. -func (f *FsRepo) ReadStats(ctx context.Context, id NodeId) (*NodeStats, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - nodeDir := filepath.Join(f.Root, id.Path()) - statsPath := filepath.Join(nodeDir, f.StatsFilename) - raw, err := f.runtime.ReadFile(statsPath) - if err != nil { - if os.IsNotExist(err) { - // No stats.json: fall back to stats embedded in the node's meta - // file. ParseStats expects JSON, so a YAML-only meta yields - // ErrNotExist. - meta, lerr := f.ReadMeta(ctx, id) - if lerr != nil || len(bytes.TrimSpace(meta)) == 0 { - return nil, ErrNotExist - } - stats, perr := ParseStats(ctx, meta) - if perr != nil { - return nil, ErrNotExist - } - return stats, nil - } - return nil, NewBackendError(f.Name(), "ReadStats", 0, err, false) - } - - stats, err := ParseStats(ctx, raw) - if err != nil { - return nil, NewBackendError(f.Name(), "ReadStats", 0, err, false) - } - return stats, nil -} - -func (f *FsRepo) NodeFilesExist(ctx context.Context, id NodeId) (bool, bool, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return false, false, err - } - if !exists { - return false, false, nil - } - nodeDir := filepath.Join(f.Root, id.Path()) - metaPath := filepath.Join(nodeDir, f.MetaFilename) - _, metaErr := f.runtime.Stat(metaPath, false) - metaExists := metaErr == nil - if metaErr != nil && !os.IsNotExist(metaErr) { - return false, false, NewBackendError(f.Name(), "NodeFilesExist", 0, metaErr, false) - } - - statsPath := filepath.Join(nodeDir, f.StatsFilename) - _, statsErr := f.runtime.Stat(statsPath, false) - statsExists := statsErr == nil - if statsErr != nil && !os.IsNotExist(statsErr) { - return false, false, NewBackendError(f.Name(), "NodeFilesExist", 0, statsErr, false) - } - - return metaExists, statsExists, nil -} - -func (f *FsRepo) ListNodes(ctx context.Context) ([]NodeId, error) { - entries, err := f.runtime.ReadDir(f.Root) - if err != nil { - return nil, NewBackendError(f.Name(), "ListNodes", 0, err, false) - } - var ids []NodeId - for _, e := range entries { - if !e.IsDir() { - continue - } - // Only include directory names that parse as valid NodeId identifiers. - if n, perr := ParseNode(e.Name()); perr == nil && n != nil && n.Valid() { - ids = append(ids, *n) - } - } - // sort ascending using NodeId.Compare for deterministic ordering - slices.SortFunc(ids, func(a, b NodeId) int { return a.Compare(b) }) - return ids, nil -} - -// ListSchemas lists schema type names stored under schemas/*.schema.yaml. -func (f *FsRepo) ListSchemas(ctx context.Context) ([]string, error) { - _ = ctx - schemaDir := filepath.Join(f.Root, SchemasDir) - entries, err := f.runtime.ReadDir(schemaDir) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil - } - return nil, NewBackendError(f.Name(), "ListSchemas", 0, err, false) - } - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - names = append(names, entry.Name()) - } - return schemaTypesFromFiles(names), nil -} - -// ReadSchema reads schemas/.schema.yaml. -func (f *FsRepo) ReadSchema(ctx context.Context, typeName string) ([]byte, error) { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return nil, err - } - data, err := f.runtime.ReadFile(filepath.Join(f.Root, SchemasDir, filename)) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadSchema", 0, err, false) - } - return data, nil -} - -// WriteSchema writes schemas/.schema.yaml atomically. -func (f *FsRepo) WriteSchema(ctx context.Context, typeName string, data []byte) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - schemaDir := filepath.Join(f.Root, SchemasDir) - if err := f.runtime.Mkdir(schemaDir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteSchema", 0, err, false) - } - if err := f.runtime.AtomicWriteFile(filepath.Join(schemaDir, filename), data, 0o644); err != nil { - return NewBackendError(f.Name(), "WriteSchema", 0, err, false) - } - return nil -} - -func (f *FsRepo) CreateSchema(ctx context.Context, typeName string, data []byte) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - schemaDir := filepath.Join(f.Root, SchemasDir) - if err := f.runtime.Mkdir(schemaDir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "CreateSchema", 0, err, false) - } - path := filepath.Join(schemaDir, filename) - w, err := f.runtime.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err != nil { - if os.IsExist(err) { - return ErrExist - } - return NewBackendError(f.Name(), "CreateSchema", 0, err, false) - } - _, writeErr := io.Copy(w, bytes.NewReader(data)) - closeErr := w.Close() - if writeErr != nil || closeErr != nil { - _ = f.runtime.Remove(path, false) - return NewBackendError(f.Name(), "CreateSchema", 0, errors.Join(writeErr, closeErr), false) - } - return nil -} - -// DeleteSchema removes schemas/.schema.yaml. -func (f *FsRepo) DeleteSchema(ctx context.Context, typeName string) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - err = f.runtime.Remove(filepath.Join(f.Root, SchemasDir, filename), false) - if err != nil { - if os.IsNotExist(err) { - return ErrNotExist - } - return NewBackendError(f.Name(), "DeleteSchema", 0, err, false) - } - return nil -} - -// ListAssets implements Repository. -func (f *FsRepo) ListAssets(ctx context.Context, id NodeId, kind AssetKind) ([]string, error) { - nodeDir := filepath.Join(f.Root, id.Path()) - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("node %s does not exist: %w", nodeDir, ErrNotExist) - } - - var dir string - switch kind { - case AssetKindImage: - dir = filepath.Join(nodeDir, NodeImagesDir) - case AssetKindItem: - dir = filepath.Join(nodeDir, NodeAttachmentsDir) - default: - return nil, fmt.Errorf("unknown asset kind %q", kind) - } - - entries, err := f.runtime.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil - } - return nil, NewBackendError(f.Name(), "ListAssets", 0, err, false) - } - - var names []string - for _, e := range entries { - if kind == AssetKindImage && e.Name() == ".meta" { - continue - } - names = append(names, e.Name()) - } - sortStrings(names) - return names, nil -} - -// WriteContent implements Repository. -func (f *FsRepo) WriteContent(ctx context.Context, id NodeId, data []byte) error { - nodeDir := filepath.Join(f.Root, id.Path()) - contentPath := filepath.Join(nodeDir, f.ContentFilename) - - // Create parent directory if it doesn't exist. - dir := filepath.Dir(contentPath) - if err := f.runtime.Mkdir(dir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteContent", 0, err, false) - } - - err := f.runtime.AtomicWriteFile(contentPath, data, 0o644) - if err != nil { - return NewBackendError(f.Name(), "WriteContent", 0, err, false) - } - return nil -} - -// WriteMeta implements Repository. -func (f *FsRepo) WriteMeta(ctx context.Context, id NodeId, data []byte) error { - nodeDir := filepath.Join(f.Root, id.Path()) - metaPath := filepath.Join(nodeDir, f.MetaFilename) - - // Create parent directory if it doesn't exist. - dir := filepath.Dir(metaPath) - if err := f.runtime.Mkdir(dir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteMeta", 0, err, false) - } - - err := f.runtime.AtomicWriteFile(metaPath, data, 0o644) - if err != nil { - return NewBackendError(f.Name(), "WriteMeta", 0, err, false) - } - return nil -} - -// WriteStats implements Repository. -func (f *FsRepo) WriteStats(ctx context.Context, id NodeId, stats *NodeStats) error { - if stats == nil { - stats = &NodeStats{} - } - - nodeDir := filepath.Join(f.Root, id.Path()) - statsPath := filepath.Join(nodeDir, f.StatsFilename) - - // Create parent directory if it doesn't exist. - dir := filepath.Dir(statsPath) - if err := f.runtime.Mkdir(dir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteStats", 0, err, false) - } - - data, err := stats.ToJSON() - if err != nil { - return NewBackendError(f.Name(), "WriteStats", 0, err, false) - } - if err := f.runtime.AtomicWriteFile(statsPath, data, 0o644); err != nil { - return NewBackendError(f.Name(), "WriteStats", 0, err, false) - } - return nil -} - -// WriteAsset implements Repository. -func (f *FsRepo) WriteAsset(ctx context.Context, id NodeId, kind AssetKind, name string, data []byte) error { - if err := validAssetName(name); err != nil { - return err - } - nodeDir := filepath.Join(f.Root, id.Path()) - exists, err := f.HasNode(ctx, id) - if err != nil { - return err - } - if !exists { - return ErrNotExist - } - - var assetPath string - switch kind { - case AssetKindImage: - assetPath = filepath.Join(nodeDir, NodeImagesDir, name) - case AssetKindItem: - assetPath = filepath.Join(nodeDir, NodeAttachmentsDir, name) - default: - return fmt.Errorf("unknown asset kind %q", kind) - } - - // Create parent directory if it doesn't exist - dir := filepath.Dir(assetPath) - if err := f.runtime.Mkdir(dir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteAsset", 0, err, false) - } - - err = f.runtime.AtomicWriteFile(assetPath, data, 0o0644) - if err != nil { - return NewBackendError(f.Name(), "WriteAsset", 0, err, false) - } - - return nil -} - -// MoveNode implements Repository. -func (f *FsRepo) MoveNode(ctx context.Context, id NodeId, dst NodeId) error { - src := filepath.Join(f.Root, id.Path()) - srcExists, err := f.HasNode(ctx, id) - if err != nil { - return err - } - if !srcExists { - return ErrNotExist - } - - dstPath := filepath.Join(f.Root, dst.Path()) - dstExists, err := f.HasNode(ctx, dst) - if err != nil { - return err - } - if dstExists { - return ErrDestinationExists - } - - if err := f.runtime.Rename(src, dstPath); err != nil { - return NewBackendError(f.Name(), "MoveNode", 0, err, false) - } - return nil -} - -// GetIndex implements Repository. -func (f *FsRepo) GetIndex(ctx context.Context, name string) ([]byte, error) { - idxPath := filepath.Join(f.Root, "dex", name) - b, err := f.runtime.ReadFile(idxPath) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "GetIndex", 0, err, false) - } - // return a copy (ReadFile already returns a copy) - return append([]byte(nil), b...), nil -} - -func (f *FsRepo) ClearIndexes(ctx context.Context) error { - dexDir := filepath.Join(f.Root, "dex") - - // If dex directory doesn't exist, nothing to clear. - if _, statErr := f.runtime.Stat(dexDir, false); statErr != nil { - if os.IsNotExist(statErr) { - return nil - } - return NewBackendError(f.Name(), "ClearIndexes", 0, statErr, false) - } - - entries, readErr := f.runtime.ReadDir(dexDir) - if readErr != nil { - return NewBackendError(f.Name(), "ClearIndexes", 0, readErr, false) - } - - for _, e := range entries { - path := filepath.Join(dexDir, e.Name()) - if rmErr := f.runtime.Remove(path, true); rmErr != nil { - return NewBackendError(f.Name(), "ClearIndexes", 0, rmErr, false) - } - } - - return nil -} - -// WriteIndex implements Repository. -func (f *FsRepo) WriteIndex(ctx context.Context, name string, data []byte) error { - idxPath := filepath.Join(f.Root, "dex", name) - err := f.runtime.AtomicWriteFile(idxPath, data, 0o0644) - if err != nil { - return NewBackendError(f.Name(), "WriteIndex", 0, err, false) - } - return nil -} - -// ListIndexes implements Repository. -func (f *FsRepo) ListIndexes(ctx context.Context) ([]string, error) { - dexDir := filepath.Join(f.Root, "dex") - entries, err := f.runtime.ReadDir(dexDir) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil - } - return nil, NewBackendError(f.Name(), "ListIndexes", 0, err, false) - } - var names []string - for _, e := range entries { - if !e.IsDir() { - names = append(names, e.Name()) - } - } - sortStrings(names) - return names, nil -} - -// DeleteNode implements Repository. -func (f *FsRepo) DeleteNode(ctx context.Context, id NodeId) error { - nodeDir := filepath.Join(f.Root, id.Path()) - exists, err := f.HasNode(ctx, id) - if err != nil { - return err - } - if !exists { - return ErrNotExist - } - - if err := f.runtime.Remove(nodeDir, true); err != nil { - return NewBackendError(f.Name(), "DeleteNode", 0, err, false) - } - return nil -} - -// DeleteAsset implements Repository. -func (f *FsRepo) DeleteAsset(ctx context.Context, id NodeId, kind AssetKind, name string) error { - if err := validAssetName(name); err != nil { - return err - } - nodeDir := filepath.Join(f.Root, id.Path()) - - // Ensure node exists - exists, err := f.HasNode(ctx, id) - if err != nil { - return err - } - if !exists { - return ErrNotExist - } - - switch kind { - case AssetKindImage: - imagesDir := filepath.Join(nodeDir, NodeImagesDir) - imagePath := filepath.Join(imagesDir, name) - if _, statErr := f.runtime.Stat(imagePath, false); statErr != nil { - if os.IsNotExist(statErr) { - return ErrNotExist - } - return NewBackendError(f.Name(), "DeleteAsset", 0, statErr, false) - } - if err := f.runtime.Remove(imagePath, true); err != nil { - return NewBackendError(f.Name(), "DeleteAsset", 0, err, false) - } - metaPath := filepath.Join(imagesDir, ".meta", name+".json") - _ = f.runtime.Remove(metaPath, false) - thumbPath := filepath.Join(imagesDir, "thumbs", name) - _ = f.runtime.Remove(thumbPath, false) - return nil - case AssetKindItem: - itemPath := filepath.Join(nodeDir, NodeAttachmentsDir, name) - if _, statErr := f.runtime.Stat(itemPath, false); statErr != nil { - if os.IsNotExist(statErr) { - return ErrNotExist - } - return NewBackendError(f.Name(), "DeleteAsset", 0, statErr, false) - } - if err := f.runtime.Remove(itemPath, true); err != nil { - return NewBackendError(f.Name(), "DeleteAsset", 0, err, false) - } - return nil - default: - return fmt.Errorf("unknown asset kind %q", kind) - } -} - -func (f *FsRepo) ListFiles(ctx context.Context, id NodeId) ([]string, error) { - return f.ListAssets(ctx, id, AssetKindItem) -} - -func (f *FsRepo) ListImages(ctx context.Context, id NodeId) ([]string, error) { - return f.ListAssets(ctx, id, AssetKindImage) -} - -func (f *FsRepo) ReadFile(ctx context.Context, id NodeId, name string) ([]byte, error) { - if err := validAssetName(name); err != nil { - return nil, err - } - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - filePath := filepath.Join(f.Root, id.Path(), NodeAttachmentsDir, name) - if _, statErr := f.runtime.Stat(filePath, false); statErr != nil { - if os.IsNotExist(statErr) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadFile", 0, statErr, false) - } - b, err := f.runtime.ReadFile(filePath) - if err != nil { - return nil, NewBackendError(f.Name(), "ReadFile", 0, err, false) - } - return b, nil -} - -func (f *FsRepo) ReadImage(ctx context.Context, id NodeId, name string) ([]byte, error) { - if err := validAssetName(name); err != nil { - return nil, err - } - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - imagePath := filepath.Join(f.Root, id.Path(), NodeImagesDir, name) - if _, statErr := f.runtime.Stat(imagePath, false); statErr != nil { - if os.IsNotExist(statErr) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadImage", 0, statErr, false) - } - b, err := f.runtime.ReadFile(imagePath) - if err != nil { - return nil, NewBackendError(f.Name(), "ReadImage", 0, err, false) - } - return b, nil -} - -func (f *FsRepo) WriteImage(ctx context.Context, id NodeId, name string, data []byte) error { - return f.WriteAsset(ctx, id, AssetKindImage, name, data) -} - -func (f *FsRepo) WriteFile(ctx context.Context, id NodeId, name string, data []byte) error { - return f.WriteAsset(ctx, id, AssetKindItem, name, data) -} - -func (f *FsRepo) DeleteImage(ctx context.Context, id NodeId, name string) error { - return f.DeleteAsset(ctx, id, AssetKindImage, name) -} - -func (f *FsRepo) DeleteFile(ctx context.Context, id NodeId, name string) error { - return f.DeleteAsset(ctx, id, AssetKindItem, name) -} - -// ReadConfig implements Repository. -func (f *FsRepo) ReadConfig(ctx context.Context) (*Config, error) { - candidates := []string{"keg", "keg.yaml", "keg.yml"} - for _, c := range candidates { - p := filepath.Join(f.Root, c) - if _, err := f.runtime.Stat(p, false); err == nil { - b, rerr := f.runtime.ReadFile(p) - if rerr != nil { - return nil, NewBackendError(f.Name(), "ReadConfig", 0, rerr, false) - } - cfg, perr := ParseKegConfig(b) - if perr != nil { - return nil, NewBackendError(f.Name(), "ReadConfig", 0, perr, false) - } - return cfg, nil - } - } - return nil, ErrNotExist -} - -// WriteConfig implements Repository. -func (f *FsRepo) WriteConfig(ctx context.Context, config *Config) error { - out, err := config.ToYAML() - if err != nil { - return NewBackendError(f.Name(), "WriteConfig", 0, err, false) - } - target := filepath.Join(f.Root, "keg") - - // Create parent directory if it doesn't exist - dir := filepath.Dir(target) - if err := f.runtime.Mkdir(dir, 0o755, true); err != nil { - return NewBackendError(f.Name(), "WriteConfig", 0, err, false) - } - - err = f.runtime.AtomicWriteFile(target, out, 0o0644) - if err != nil { - return NewBackendError(f.Name(), "WriteConfig", 0, err, false) - } - return nil -} - -// ContentFilePath returns the absolute filesystem path to a node's content file. -func (f *FsRepo) ContentFilePath(id NodeId) string { - return filepath.Join(f.Root, id.Path(), f.ContentFilename) -} - -// MetaFilePath returns the absolute filesystem path to a node's metadata file. -func (f *FsRepo) MetaFilePath(id NodeId) string { - return filepath.Join(f.Root, id.Path(), f.MetaFilename) -} - -// NodeDirPath returns the absolute filesystem path to a node's directory. -func (f *FsRepo) NodeDirPath(id NodeId) string { - return filepath.Join(f.Root, id.Path()) -} - -// registerWatcher adds a watch subscriber to the active set for access event emission. -func (f *FsRepo) registerWatcher(w *fsWatch) { - f.watchersMu.Lock() - f.watchers = append(f.watchers, w) - f.watchersMu.Unlock() -} - -// unregisterWatcher removes a watch subscriber from the active set. -func (f *FsRepo) unregisterWatcher(w *fsWatch) { - f.watchersMu.Lock() - defer f.watchersMu.Unlock() - for i, active := range f.watchers { - if active == w { - f.watchers = append(f.watchers[:i], f.watchers[i+1:]...) - return - } - } -} - -// emitToWatchers broadcasts a NodeEvent to all active watch subscribers. -func (f *FsRepo) emitToWatchers(ev NodeEvent) { - f.watchersMu.Lock() - defer f.watchersMu.Unlock() - for _, w := range f.watchers { - w.emit(ev) - } -} - -var _ Repository = (*FsRepo)(nil) -var _ RepositoryFiles = (*FsRepo)(nil) -var _ RepositoryImages = (*FsRepo)(nil) -var _ RepositorySchemas = (*FsRepo)(nil) - -// ----------------- small helpers ----------------- - -func sortStrings(ss []string) { - slices.Sort(ss) -} diff --git a/pkg/keg/repo_filesystem_lock.go b/pkg/keg/repo_filesystem_lock.go deleted file mode 100644 index efd59db6..00000000 --- a/pkg/keg/repo_filesystem_lock.go +++ /dev/null @@ -1,146 +0,0 @@ -package keg - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "time" -) - -const ( - lockTokenFile = "lock.json" - KegCrossLockFile = ".keg-cross-lock" -) - -func (f *FsRepo) crossLockPath(id NodeId) string { - return filepath.Join(f.Root, id.Path(), KegCrossLockFile) -} - -func (f *FsRepo) crossLockTokenPath(id NodeId) string { - return filepath.Join(f.crossLockPath(id), lockTokenFile) -} - -// AcquireLock implements RepositoryLock. -func (f *FsRepo) AcquireLock(ctx context.Context, id NodeId) (LockToken, error) { - nodeDir := filepath.Join(f.Root, id.Path()) - if err := f.runtime.Mkdir(nodeDir, 0o755, true); err != nil { - return "", errors.Join(ErrLock, NewBackendError(f.Name(), "AcquireLock", 0, err, false)) - } - - lockPath := f.crossLockPath(id) - for { - err := f.runtime.Mkdir(lockPath, 0o700, false) - if err == nil { - // Lock directory created — write token metadata. - token := generateLockToken() - info := LockInfo{ - Token: token, - AcquiredAt: f.runtime.Clock().Now(), - TTLSeconds: int(DefaultLockTTL / time.Second), - Holder: f.lockHolder(), - } - if writeErr := f.writeCrossLockInfo(id, info); writeErr != nil { - // Clean up the lock directory on write failure. - _ = f.runtime.Remove(lockPath, true) - return "", errors.Join(ErrLock, NewBackendError(f.Name(), "AcquireLock", 0, writeErr, false)) - } - return token, nil - } - if os.IsExist(err) { - // Lock directory exists — check if it's stale. - info, readErr := f.readCrossLockInfo(id) - if readErr != nil || info.IsStale(f.runtime.Clock().Now()) { - // Stale or unreadable — remove and retry. - _ = f.runtime.Remove(lockPath, true) - continue - } - // Active lock held by someone else — wait or bail. - select { - case <-ctx.Done(): - return "", fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-time.After(100 * time.Millisecond): - } - continue - } - return "", errors.Join(ErrLock, NewBackendError(f.Name(), "AcquireLock", 0, err, false)) - } -} - -// ReleaseLock implements RepositoryLock. -func (f *FsRepo) ReleaseLock(ctx context.Context, id NodeId, token LockToken) error { - info, err := f.readCrossLockInfo(id) - if err != nil { - if os.IsNotExist(err) { - return ErrNotLocked - } - return errors.Join(ErrLock, NewBackendError(f.Name(), "ReleaseLock", 0, err, false)) - } - if info.Token == "" { - return ErrNotLocked - } - if info.Token != token { - return fmt.Errorf("%w: lock held by %q", ErrLockTokenMismatch, info.Holder) - } - lockPath := f.crossLockPath(id) - if rmErr := f.runtime.Remove(lockPath, true); rmErr != nil && !os.IsNotExist(rmErr) { - return errors.Join(ErrLock, NewBackendError(f.Name(), "ReleaseLock", 0, rmErr, false)) - } - return nil -} - -// LockStatus implements RepositoryLock. -func (f *FsRepo) LockStatus(ctx context.Context, id NodeId) (LockInfo, error) { - info, err := f.readCrossLockInfo(id) - if err != nil { - if os.IsNotExist(err) { - return LockInfo{}, nil - } - return LockInfo{}, errors.Join(ErrLock, NewBackendError(f.Name(), "LockStatus", 0, err, false)) - } - if info.IsStale(f.runtime.Clock().Now()) { - return LockInfo{}, nil - } - return info, nil -} - -// ForceReleaseLock implements RepositoryLock. -func (f *FsRepo) ForceReleaseLock(ctx context.Context, id NodeId) error { - lockPath := f.crossLockPath(id) - if err := f.runtime.Remove(lockPath, true); err != nil && !os.IsNotExist(err) { - return errors.Join(ErrLock, NewBackendError(f.Name(), "ForceReleaseLock", 0, err, false)) - } - return nil -} - -func (f *FsRepo) readCrossLockInfo(id NodeId) (LockInfo, error) { - tokenPath := f.crossLockTokenPath(id) - data, err := f.runtime.ReadFile(tokenPath) - if err != nil { - return LockInfo{}, err - } - var info LockInfo - if err := json.Unmarshal(data, &info); err != nil { - return LockInfo{}, err - } - return info, nil -} - -func (f *FsRepo) writeCrossLockInfo(id NodeId, info LockInfo) error { - data, err := json.Marshal(info) - if err != nil { - return err - } - return f.runtime.WriteFile(f.crossLockTokenPath(id), data, 0o644) -} - -func (f *FsRepo) lockHolder() string { - if pi := f.runtime.Process(); pi != nil { - return fmt.Sprintf("pid:%d@%s", pi.PID, pi.Hostname) - } - return "tap-cli" -} - -var _ RepositoryLock = (*FsRepo)(nil) diff --git a/pkg/keg/repo_filesystem_snapshots.go b/pkg/keg/repo_filesystem_snapshots.go deleted file mode 100644 index ca6735a9..00000000 --- a/pkg/keg/repo_filesystem_snapshots.go +++ /dev/null @@ -1,393 +0,0 @@ -package keg - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -const defaultSnapshotCheckpointInterval = 20 - -func (f *FsRepo) snapshotCheckpointInterval() int { - if f == nil || f.SnapshotCheckpointInterval <= 0 { - return defaultSnapshotCheckpointInterval - } - return f.SnapshotCheckpointInterval -} - -func (f *FsRepo) AppendSnapshot(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { - if contextHasNodeLock(ctx, id) { - return f.appendSnapshotLocked(ctx, id, in) - } - - var out Snapshot - err := f.WithNodeLock(ctx, id, func(lockCtx context.Context) error { - snap, err := f.appendSnapshotLocked(lockCtx, id, in) - if err != nil { - return err - } - out = snap - return nil - }) - return out, err -} - -func (f *FsRepo) appendSnapshotLocked(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return Snapshot{}, err - } - if !exists { - return Snapshot{}, ErrNotExist - } - - index, err := f.readSnapshotIndex(ctx, id) - if err != nil { - return Snapshot{}, err - } - - var parent RevisionID - if len(index) > 0 { - parent = index[len(index)-1].ID - } - if in.ExpectedParent != parent { - return Snapshot{}, fmt.Errorf("expected parent %d, got %d: %w", in.ExpectedParent, parent, ErrConflict) - } - - content, meta, statsBytes, err := normalizeSnapshotWrite(ctx, f.runtime, in) - if err != nil { - return Snapshot{}, err - } - contentHash, metaHash, statsHash := snapshotWriteHashes(f.runtime, content, meta, statsBytes) - createdAt := in.CreatedAt - if createdAt.IsZero() { - createdAt = f.runtime.Clock().Now() - } - - storeFull := len(index) == 0 || in.Content.Kind == SnapshotContentKindFull - if !storeFull && f.snapshotCheckpointInterval() > 0 { - patches := 0 - for i := len(index) - 1; i >= 0; i-- { - if index[i].IsCheckpoint { - break - } - patches++ - } - if patches >= f.snapshotCheckpointInterval() { - storeFull = true - } - } - - snapshot := Snapshot{ - ID: parent + 1, - Node: id, - Parent: parent, - CreatedAt: createdAt, - Message: in.Message, - ContentHash: contentHash, - MetaHash: metaHash, - StatsHash: statsHash, - IsCheckpoint: storeFull, - } - - if err := f.runtime.Mkdir(f.snapshotDir(id), 0o755, true); err != nil { - return Snapshot{}, NewBackendError(f.Name(), "AppendSnapshotMkdir", 0, err, false) - } - - if storeFull { - if err := f.runtime.AtomicWriteFile(f.snapshotContentPath(id, snapshot.ID, SnapshotContentKindFull), content, 0o644); err != nil { - return Snapshot{}, NewBackendError(f.Name(), "AppendSnapshotWriteContent", 0, err, false) - } - } else { - baseContent, err := f.readContentAtIndex(ctx, id, index, parent) - if err != nil { - return Snapshot{}, err - } - patchBytes, err := buildSnapshotPatch(f.runtime.Hasher(), baseContent, content) - if err != nil { - return Snapshot{}, err - } - if err := f.runtime.AtomicWriteFile(f.snapshotContentPath(id, snapshot.ID, SnapshotContentKindPatch), patchBytes, 0o644); err != nil { - return Snapshot{}, NewBackendError(f.Name(), "AppendSnapshotWritePatch", 0, err, false) - } - } - - if err := f.runtime.AtomicWriteFile(f.snapshotMetaPath(id, snapshot.ID), meta, 0o644); err != nil { - return Snapshot{}, NewBackendError(f.Name(), "AppendSnapshotWriteMeta", 0, err, false) - } - if err := f.runtime.AtomicWriteFile(f.snapshotStatsPath(id, snapshot.ID), statsBytes, 0o644); err != nil { - return Snapshot{}, NewBackendError(f.Name(), "AppendSnapshotWriteStats", 0, err, false) - } - - index = append(index, snapshot) - if err := f.writeSnapshotIndex(id, index); err != nil { - return Snapshot{}, err - } - - return snapshot, nil -} - -func (f *FsRepo) GetSnapshot(ctx context.Context, id NodeId, rev RevisionID, opts SnapshotReadOptions) (Snapshot, []byte, []byte, *NodeStats, error) { - index, err := f.readSnapshotIndex(ctx, id) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - - snap, err := snapshotFromIndex(index, rev) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - - var content []byte - if opts.ResolveContent { - content, err = f.readContentAtIndex(ctx, id, index, rev) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - } - - meta, err := f.runtime.ReadFile(f.snapshotMetaPath(id, rev)) - if err != nil { - if os.IsNotExist(err) { - return Snapshot{}, nil, nil, nil, ErrNotExist - } - return Snapshot{}, nil, nil, nil, NewBackendError(f.Name(), "GetSnapshotMeta", 0, err, false) - } - - statsBytes, err := f.runtime.ReadFile(f.snapshotStatsPath(id, rev)) - if err != nil { - if os.IsNotExist(err) { - return Snapshot{}, nil, nil, nil, ErrNotExist - } - return Snapshot{}, nil, nil, nil, NewBackendError(f.Name(), "GetSnapshotStats", 0, err, false) - } - stats, err := snapshotStatsFromBytes(ctx, statsBytes) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - - return snap, content, meta, stats, nil -} - -func (f *FsRepo) ListSnapshots(ctx context.Context, id NodeId) ([]Snapshot, error) { - index, err := f.readSnapshotIndex(ctx, id) - if err != nil { - return nil, err - } - out := make([]Snapshot, len(index)) - copy(out, index) - return out, nil -} - -func (f *FsRepo) ReadContentAt(ctx context.Context, id NodeId, rev RevisionID) ([]byte, error) { - index, err := f.readSnapshotIndex(ctx, id) - if err != nil { - return nil, err - } - return f.readContentAtIndex(ctx, id, index, rev) -} - -func (f *FsRepo) RestoreSnapshot(ctx context.Context, id NodeId, rev RevisionID, createRestoreSnapshot bool) error { - if contextHasNodeLock(ctx, id) { - return f.restoreSnapshotLocked(ctx, id, rev, createRestoreSnapshot) - } - return f.WithNodeLock(ctx, id, func(lockCtx context.Context) error { - return f.restoreSnapshotLocked(lockCtx, id, rev, createRestoreSnapshot) - }) -} - -func (f *FsRepo) restoreSnapshotLocked(ctx context.Context, id NodeId, rev RevisionID, createRestoreSnapshot bool) error { - index, err := f.readSnapshotIndex(ctx, id) - if err != nil { - return err - } - if _, err := snapshotFromIndex(index, rev); err != nil { - return err - } - - content, err := f.readContentAtIndex(ctx, id, index, rev) - if err != nil { - return err - } - meta, err := f.runtime.ReadFile(f.snapshotMetaPath(id, rev)) - if err != nil { - if os.IsNotExist(err) { - return ErrNotExist - } - return NewBackendError(f.Name(), "RestoreSnapshotMeta", 0, err, false) - } - statsBytes, err := f.runtime.ReadFile(f.snapshotStatsPath(id, rev)) - if err != nil { - if os.IsNotExist(err) { - return ErrNotExist - } - return NewBackendError(f.Name(), "RestoreSnapshotStats", 0, err, false) - } - stats, err := snapshotStatsFromBytes(ctx, statsBytes) - if err != nil { - return err - } - - if err := f.WriteContent(ctx, id, content); err != nil { - return err - } - if err := f.WriteMeta(ctx, id, meta); err != nil { - return err - } - if err := f.WriteStats(ctx, id, stats); err != nil { - return err - } - if !createRestoreSnapshot { - return nil - } - - var parent RevisionID - if len(index) > 0 { - parent = index[len(index)-1].ID - } - _, err = f.appendSnapshotLocked(ctx, id, SnapshotWrite{ - ExpectedParent: parent, - Message: fmt.Sprintf("restore from rev %d", rev), - Meta: meta, - Stats: stats, - Content: SnapshotContentWrite{ - Kind: SnapshotContentKindFull, - Data: content, - Hash: hashSnapshotBytes(f.runtime, content), - }, - }) - return err -} - -func (f *FsRepo) readSnapshotIndex(ctx context.Context, id NodeId) ([]Snapshot, error) { - exists, err := f.HasNode(ctx, id) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrNotExist - } - - path := f.snapshotIndexPath(id) - raw, err := f.runtime.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return []Snapshot{}, nil - } - return nil, NewBackendError(f.Name(), "ReadSnapshotIndex", 0, err, false) - } - if len(raw) == 0 { - return []Snapshot{}, nil - } - - var index []Snapshot - if err := json.Unmarshal(raw, &index); err != nil { - return nil, NewBackendError(f.Name(), "ReadSnapshotIndex", 0, err, false) - } - return index, nil -} - -func (f *FsRepo) writeSnapshotIndex(id NodeId, index []Snapshot) error { - raw, err := json.MarshalIndent(index, "", " ") - if err != nil { - return NewBackendError(f.Name(), "WriteSnapshotIndex", 0, err, false) - } - if err := f.runtime.AtomicWriteFile(f.snapshotIndexPath(id), raw, 0o644); err != nil { - return NewBackendError(f.Name(), "WriteSnapshotIndex", 0, err, false) - } - return nil -} - -func (f *FsRepo) readContentAtIndex(ctx context.Context, id NodeId, index []Snapshot, rev RevisionID) ([]byte, error) { - if _, err := snapshotFromIndex(index, rev); err != nil { - return nil, err - } - - var start int = -1 - for i := len(index) - 1; i >= 0; i-- { - if index[i].ID > rev { - continue - } - if index[i].IsCheckpoint { - start = i - break - } - } - if start == -1 { - return nil, fmt.Errorf("snapshot %d has no checkpoint: %w", rev, ErrInvalid) - } - - fullPath := f.snapshotContentPath(id, index[start].ID, SnapshotContentKindFull) - content, err := f.runtime.ReadFile(fullPath) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadSnapshotFull", 0, err, false) - } - - for i := start + 1; i < len(index); i++ { - snap := index[i] - if snap.ID > rev { - break - } - patchPath := f.snapshotContentPath(id, snap.ID, SnapshotContentKindPatch) - patchBytes, err := f.runtime.ReadFile(patchPath) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotExist - } - return nil, NewBackendError(f.Name(), "ReadSnapshotPatch", 0, err, false) - } - content, err = applySnapshotPatch(f.runtime.Hasher(), content, patchBytes) - if err != nil { - return nil, err - } - if snap.ContentHash != "" && snap.ContentHash != hashSnapshotBytes(f.runtime, content) { - return nil, fmt.Errorf("snapshot content hash mismatch for rev %d: %w", snap.ID, ErrConflict) - } - } - - if expected, err := snapshotFromIndex(index, rev); err == nil && expected.ContentHash != "" && expected.ContentHash != hashSnapshotBytes(f.runtime, content) { - return nil, fmt.Errorf("snapshot content hash mismatch for rev %d: %w", rev, ErrConflict) - } - _ = ctx - return content, nil -} - -func snapshotFromIndex(index []Snapshot, rev RevisionID) (Snapshot, error) { - for _, snap := range index { - if snap.ID == rev { - return snap, nil - } - } - return Snapshot{}, ErrNotExist -} - -func (f *FsRepo) snapshotDir(id NodeId) string { - return filepath.Join(f.Root, id.Path(), "snapshots") -} - -func (f *FsRepo) snapshotIndexPath(id NodeId) string { - return filepath.Join(f.snapshotDir(id), "index.json") -} - -func (f *FsRepo) snapshotMetaPath(id NodeId, rev RevisionID) string { - return filepath.Join(f.snapshotDir(id), fmt.Sprintf("%d.meta", rev)) -} - -func (f *FsRepo) snapshotStatsPath(id NodeId, rev RevisionID) string { - return filepath.Join(f.snapshotDir(id), fmt.Sprintf("%d.stats", rev)) -} - -func (f *FsRepo) snapshotContentPath(id NodeId, rev RevisionID, kind SnapshotContentKind) string { - ext := ".full" - if kind == SnapshotContentKindPatch { - ext = ".patch" - } - return filepath.Join(f.snapshotDir(id), fmt.Sprintf("%d%s", rev, ext)) -} - -var _ RepositorySnapshots = (*FsRepo)(nil) diff --git a/pkg/keg/repo_filesystem_test.go b/pkg/keg/repo_filesystem_test.go deleted file mode 100644 index f2856ccf..00000000 --- a/pkg/keg/repo_filesystem_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package keg_test - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -func TestFsRepo_WriteReadMetaAndContent(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, sandbox.WithFixture("empty", "~/empty")) - ctx := fx.Context() - - r := keg.NewFsRepo("~/empty", fx.Runtime()) - - fx.DumpJailTree(0) - id := keg.NodeId{ID: 10} - content := []byte("# hello\n") - meta := []byte("title: test\nupdated: 2025-08-11 00:00:00Z\n") - - require.NoError(t, r.WriteContent(ctx, id, content)) - require.NoError(t, r.WriteMeta(ctx, id, meta)) - - gotContent, err := r.ReadContent(ctx, id) - require.NoError(t, err) - require.Equal(t, string(content), string(gotContent)) - - gotMeta, err := r.ReadMeta(ctx, id) - require.NoError(t, err) - require.Equal(t, string(meta), string(gotMeta)) -} - -func TestFsRepo_HasNode(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, sandbox.WithFixture("empty", "~/empty")) - ctx := fx.Context() - - r := keg.NewFsRepo("~/empty", fx.Runtime()) - id := keg.NodeId{ID: 12} - - exists, err := r.HasNode(ctx, id) - require.NoError(t, err) - require.False(t, exists) - - require.NoError(t, r.WriteContent(ctx, id, []byte("# hello\n"))) - - exists, err = r.HasNode(ctx, id) - require.NoError(t, err) - require.True(t, exists) -} - -func TestFsRepo_NextAndListNodes(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, - sandbox.WithFixture("home", "/home"), - sandbox.WithWd("~/repofs_fs"), - ) - fx.DumpJailTree(0) - ctx := fx.Context() - - r := keg.NewFsRepo("~/repofs_fs", fx.Runtime()) - - next, err := r.Next(ctx) - require.NoError(t, err) - require.GreaterOrEqual(t, int(next.ID), 1) - - ids, err := r.ListNodes(ctx) - require.NoError(t, err) - - // expect to contain 0 and 1 - found0 := false - found1 := false - for _, n := range ids { - if n.ID == 0 { - found0 = true - } - if n.ID == 1 { - found1 = true - } - } - require.True(t, found0, "expected to find node 0") - require.True(t, found1, "expected to find node 1") -} - -func TestFsRepo_MoveDeleteNodeAndDestinationExists(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - // Use std.Mkdir to avoid direct os package functions. - require.NoError(t, os.MkdirAll(tmp, 0o755)) - - r := keg.NewFsRepo(tmp, fx.Runtime()) - - src := keg.NodeId{ID: 20} - dst := keg.NodeId{ID: 30} - other := keg.NodeId{ID: 31} - content := []byte("content") - - // prepare src node - require.NoError(t, r.WriteContent(ctx, src, content)) - require.NoError(t, r.WriteMeta(ctx, src, []byte("title: src\n"))) - - // move to dst - require.NoError(t, r.MoveNode(ctx, src, dst)) - - // src should no longer exist - _, err := r.ReadContent(ctx, src) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrNotExist) - - // dst should have content - got, err := r.ReadContent(ctx, dst) - require.NoError(t, err) - require.Equal(t, content, got) - - // create other and attempt move dst -> other to force destination-exists - require.NoError(t, r.WriteContent(ctx, other, []byte("x"))) - require.NoError(t, r.WriteMeta(ctx, other, []byte("title: other\n"))) - - err = r.MoveNode(ctx, dst, other) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrDestinationExists) - - // DeleteNode should remove node - require.NoError(t, r.DeleteNode(ctx, other)) - _, err = r.ReadContent(ctx, other) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrNotExist) -} - -func TestFsRepo_UploadAndListImagesAndItems(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - require.NoError(t, os.MkdirAll(tmp, 0o755)) - - r := keg.NewFsRepo(tmp, fx.Runtime()) - - id := keg.NodeId{ID: 40} - // ensure node exists - require.NoError(t, r.WriteContent(ctx, id, []byte("c"))) - require.NoError(t, r.WriteMeta(ctx, id, []byte("title: i\n"))) - - // images - require.NoError(t, r.WriteAsset(ctx, id, keg.AssetKindImage, "a.png", []byte("pngdata"))) - require.NoError(t, r.WriteAsset(ctx, id, keg.AssetKindImage, "b.jpg", []byte("jpgdata"))) - - images, err := r.ListAssets(ctx, id, keg.AssetKindImage) - require.NoError(t, err) - require.Contains(t, images, "a.png") - require.Contains(t, images, "b.jpg") - - // items - require.NoError(t, r.WriteAsset(ctx, id, keg.AssetKindItem, "attach.txt", []byte("data"))) - items, err := r.ListAssets(ctx, id, keg.AssetKindItem) - require.NoError(t, err) - require.Contains(t, items, "attach.txt") -} - -func TestFsRepo_WriteGetAndListIndexes(t *testing.T) { - t.Parallel() - fx := NewSandbox(t, sandbox.WithFixture("example", "~/example")) - ctx := fx.Context() - - r := keg.NewFsRepo("~/example", fx.Runtime()) - - data, err := r.GetIndex(ctx, "nodes.tsv") - require.NoError(t, err, "expect to be able to read nodes.tsv index") - require.Equal(t, string(data), "0\t2025-10-04 18:30:01Z\t2025-10-04 18:30:01Z\t2025-10-04 18:30:01Z\tSorry, planned but not yet available\n") -} - -func TestFsRepo_WriteReadStats(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - require.NoError(t, os.MkdirAll(tmp, 0o755)) - - r := keg.NewFsRepo(tmp, fx.Runtime()) - - id := keg.NodeId{ID: 88} - require.NoError(t, r.WriteMeta(ctx, id, []byte("title: keep-me\nfoo: bar\n"))) - - now := time.Date(2026, 2, 14, 12, 0, 0, 0, time.UTC) - stats := keg.NewStats(now) - stats.SetHash("h1", &now) - stats.SetLead("lead text") - stats.SetLinks([]keg.NodeId{{ID: 1}, {ID: 2}}) - stats.SetAccessed(now) - - require.NoError(t, r.WriteStats(ctx, id, stats)) - - gotStats, err := r.ReadStats(ctx, id) - require.NoError(t, err) - require.Equal(t, "h1", gotStats.Hash()) - require.Equal(t, "lead text", gotStats.Lead()) - require.Len(t, gotStats.Links(), 2) - - gotMeta, err := r.ReadMeta(ctx, id) - require.NoError(t, err) - require.Contains(t, string(gotMeta), "title: keep-me") - require.Contains(t, string(gotMeta), "foo: bar") - require.NotContains(t, string(gotMeta), "hash:") - - statsPath := filepath.Join(tmp, id.Path(), keg.JSONStatsFilename) - rawStats, err := fx.Runtime().ReadFile(statsPath) - require.NoError(t, err) - require.Contains(t, string(rawStats), "\"hash\":\"h1\"") -} - -func TestFsRepo_WithNodeLockTimeout(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - require.NoError(t, os.MkdirAll(tmp, 0o755)) - - r := keg.NewFsRepo(tmp, fx.Runtime()) - - id := keg.NodeId{ID: 91} - locked := make(chan struct{}) - release := make(chan struct{}) - done := make(chan error, 1) - - go func() { - done <- r.WithNodeLock(ctx, id, func(context.Context) error { - close(locked) - <-release - return nil - }) - }() - - <-locked - - lockCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - err := r.WithNodeLock(lockCtx, id, func(context.Context) error { - return nil - }) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrLockTimeout) - - close(release) - require.NoError(t, <-done) -} - -func TestFsRepo_WithNodeLockReentrantAndCleanup(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - require.NoError(t, os.MkdirAll(tmp, 0o755)) - - r := keg.NewFsRepo(tmp, fx.Runtime()) - - id := keg.NodeId{ID: 92} - lockPath := filepath.Join(tmp, id.Path(), keg.KegLockFile) - - err := r.WithNodeLock(ctx, id, func(lockCtx context.Context) error { - _, statErr := fx.Runtime().Stat(lockPath, false) - require.NoError(t, statErr) - - return r.WithNodeLock(lockCtx, id, func(context.Context) error { - return nil - }) - }) - require.NoError(t, err) - - _, err = fx.Runtime().Stat(lockPath, false) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) -} diff --git a/pkg/keg/repo_fs_events.go b/pkg/keg/repo_fs_events.go deleted file mode 100644 index 19aae44b..00000000 --- a/pkg/keg/repo_fs_events.go +++ /dev/null @@ -1,224 +0,0 @@ -package keg - -import ( - "context" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/fsnotify/fsnotify" -) - -// fsWatch is the per-Watch handle for FsRepo live events: one fsnotify -// watcher and one subscriber channel, both scoped to the Watch context. -type fsWatch struct { - repo *FsRepo - watcher *fsnotify.Watcher - resolvedRoot string // real filesystem path for fsnotify and classify - ch chan NodeEvent - ids map[NodeId]struct{} // empty means all nodes - done chan struct{} // closed when loop exits -} - -// Watch implements RepositoryEvents for FsRepo using fsnotify. When no IDs -// are given, the entire keg root is watched and events for any node are -// emitted. Events are delivered on the returned channel until ctx is -// cancelled, at which point the channel is closed. -func (f *FsRepo) Watch(ctx context.Context, ids ...NodeId) (<-chan NodeEvent, error) { - w, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - - // Resolve the root path through the runtime so that jailed/sandboxed - // paths are expanded to real filesystem paths for fsnotify. - resolved := f.Root - if f.runtime != nil { - if r, resolveErr := f.runtime.ResolvePath(f.Root, false); resolveErr == nil { - resolved = r - } - // Apply jail prefix for sandboxed environments. - if jail := strings.TrimSpace(f.runtime.GetJail()); jail != "" { - trimmed := strings.TrimPrefix(resolved, string(filepath.Separator)) - resolved = filepath.Join(jail, trimmed) - } - } - - // Determine which directories to watch using resolved paths. - var dirs []string - if len(ids) == 0 { - dirs = append(dirs, resolved) - } else { - for _, id := range ids { - dirs = append(dirs, filepath.Join(resolved, id.Path())) - } - } - for _, d := range dirs { - if err := w.Add(d); err != nil { - _ = w.Close() - return nil, err - } - } - - idSet := make(map[NodeId]struct{}, len(ids)) - for _, id := range ids { - idSet[id] = struct{}{} - } - fw := &fsWatch{ - repo: f, - watcher: w, - resolvedRoot: resolved, - ch: make(chan NodeEvent, 16), - ids: idSet, - done: make(chan struct{}), - } - f.registerWatcher(fw) - - go fw.loop(ctx, len(ids) == 0) - - // Cleanup: when ctx ends, unregister the subscriber first (Emit holds - // watchersMu, so no programmatic send can race the close), close the - // fsnotify watcher (unblocks the loop), wait for the loop to exit, then - // close the channel. - go func() { - <-ctx.Done() - f.unregisterWatcher(fw) - _ = w.Close() - <-fw.done - close(fw.ch) - }() - - return fw.ch, nil -} - -// Emit implements RepositoryEvents. It broadcasts a programmatic event -// (access bumps, test simulation) to all active Watch subscribers whose -// filters match. -func (f *FsRepo) Emit(ev NodeEvent) { - f.emitToWatchers(ev) -} - -// emit delivers a programmatic event to this subscriber. Called by -// FsRepo.emitToWatchers under watchersMu; sends are non-blocking so a slow -// consumer cannot deadlock the emitter. -func (fw *fsWatch) emit(ev NodeEvent) { - _, match := fw.ids[ev.NodeID] - if len(fw.ids) == 0 || match { - select { - case fw.ch <- ev: - default: - } - } -} - -// loop reads fsnotify events, debounces them, and emits NodeEvents. -// Signals completion by closing fw.done when it returns. -func (fw *fsWatch) loop(ctx context.Context, watchRoot bool) { - defer close(fw.done) - - // pending tracks debounce state per file path. - type pendingEvent struct { - event NodeEvent - first time.Time - } - pending := make(map[string]*pendingEvent) - - const debounce = 150 * time.Millisecond - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - - case fsEvent, ok := <-fw.watcher.Events: - if !ok { - return - } - ev, valid := fw.classify(fsEvent, watchRoot) - if !valid { - continue - } - pending[fsEvent.Name] = &pendingEvent{event: ev, first: time.Now()} - - case _, ok := <-fw.watcher.Errors: - if !ok { - return - } - // Swallow watcher errors; they don't map to node events. - - case <-ticker.C: - now := time.Now() - for path, p := range pending { - if now.Sub(p.first) >= debounce { - select { - case fw.ch <- p.event: - case <-ctx.Done(): - return - } - delete(pending, path) - } - } - } - } -} - -// classify maps an fsnotify.Event to a NodeEvent, returning false when the -// event does not correspond to a recognized node file. -func (fw *fsWatch) classify(ev fsnotify.Event, watchRoot bool) (NodeEvent, bool) { - // Determine which file changed and derive node ID + field. - abs := ev.Name - rel, err := filepath.Rel(fw.resolvedRoot, abs) - if err != nil { - return NodeEvent{}, false - } - - parts := strings.SplitN(filepath.ToSlash(rel), "/", 3) - if len(parts) < 1 { - return NodeEvent{}, false - } - - // Parse the first path component as a node ID. - nodeDir := parts[0] - nodeNum, parseErr := strconv.Atoi(nodeDir) - if parseErr != nil { - return NodeEvent{}, false - } - id := NodeId{ID: nodeNum} - - // Determine the field from the filename. - var field string - if len(parts) >= 2 { - base := parts[len(parts)-1] - switch base { - case fw.repo.ContentFilename: - field = "content" - case fw.repo.MetaFilename: - field = "meta" - case fw.repo.StatsFilename: - field = "stats" - default: - // Ignore changes to other files (images, assets, lock files). - return NodeEvent{}, false - } - } - - // Map fsnotify op to NodeEventKind. - var kind NodeEventKind - switch { - case ev.Op&fsnotify.Create != 0: - kind = NodeEventCreated - case ev.Op&fsnotify.Remove != 0: - kind = NodeEventDeleted - case ev.Op&(fsnotify.Write|fsnotify.Rename|fsnotify.Chmod) != 0: - kind = NodeEventModified - default: - return NodeEvent{}, false - } - - return NodeEvent{Kind: kind, NodeID: id, Field: field}, true -} - -var _ RepositoryEvents = (*FsRepo)(nil) diff --git a/pkg/keg/repo_lock_test.go b/pkg/keg/repo_lock_test.go deleted file mode 100644 index c9c3f512..00000000 --- a/pkg/keg/repo_lock_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package keg_test - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -// -- FsRepo RepositoryLock tests -- - -func TestFsRepo_AcquireAndReleaseLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 100} - - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - require.NotEmpty(t, token) - - // Status should show an active lock. - info, err := r.LockStatus(ctx, id) - require.NoError(t, err) - require.Equal(t, token, info.Token) - require.NotZero(t, info.AcquiredAt) - - // Release with correct token. - require.NoError(t, r.ReleaseLock(ctx, id, token)) - - // Status should now be empty. - info, err = r.LockStatus(ctx, id) - require.NoError(t, err) - require.Empty(t, info.Token) -} - -func TestFsRepo_ReleaseLockTokenMismatch(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 101} - - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - err = r.ReleaseLock(ctx, id, "wrong-token") - require.ErrorIs(t, err, keg.ErrLockTokenMismatch) -} - -func TestFsRepo_ReleaseLockNotLocked(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 102} - - err := r.ReleaseLock(ctx, id, "any-token") - require.ErrorIs(t, err, keg.ErrNotLocked) -} - -func TestFsRepo_AcquireLockContention(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 103} - - // First acquire succeeds. - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - // Second acquire with short timeout should fail. - lockCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) - defer cancel() - _, err = r.AcquireLock(lockCtx, id) - require.ErrorIs(t, err, keg.ErrLockTimeout) -} - -func TestFsRepo_ForceReleaseLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 104} - - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - // Force release without knowing the token. - require.NoError(t, r.ForceReleaseLock(ctx, id)) - - // Lock should now be available. - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - require.NotEmpty(t, token) -} - -func TestFsRepo_ForceReleaseLockNotLocked(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 105} - - // Force release on an unlocked node is a no-op. - require.NoError(t, r.ForceReleaseLock(ctx, id)) -} - -func TestFsRepo_CrossLockDoesNotInterfereWithNodeLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - tmp := t.TempDir() - r := keg.NewFsRepo(tmp, fx.Runtime()) - id := keg.NodeId{ID: 106} - - // Acquire a cross-process lock. - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - // WithNodeLock (process-scoped) should still work. - err = r.WithNodeLock(ctx, id, func(context.Context) error { - return nil - }) - require.NoError(t, err) - - // Cross-process lock should still be held. - info, err := r.LockStatus(ctx, id) - require.NoError(t, err) - require.Equal(t, token, info.Token) - - // Release cross-process lock. - require.NoError(t, r.ReleaseLock(ctx, id, token)) - - // Verify the cross-lock directory is gone but the process-lock dir is - // also gone (WithNodeLock cleaned up after itself). - nodeDir := filepath.Join(tmp, id.Path()) - _, err = os.Stat(filepath.Join(nodeDir, keg.KegLockFile)) - require.True(t, os.IsNotExist(err)) - _, err = os.Stat(filepath.Join(nodeDir, keg.KegCrossLockFile)) - require.True(t, os.IsNotExist(err)) -} - -// -- MemoryRepo RepositoryLock tests -- - -func TestMemoryRepo_AcquireAndReleaseLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 200} - - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - require.NotEmpty(t, token) - - info, err := r.LockStatus(ctx, id) - require.NoError(t, err) - require.Equal(t, token, info.Token) - - require.NoError(t, r.ReleaseLock(ctx, id, token)) - - info, err = r.LockStatus(ctx, id) - require.NoError(t, err) - require.Empty(t, info.Token) -} - -func TestMemoryRepo_ReleaseLockTokenMismatch(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 201} - - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - err = r.ReleaseLock(ctx, id, "wrong-token") - require.ErrorIs(t, err, keg.ErrLockTokenMismatch) -} - -func TestMemoryRepo_ReleaseLockNotLocked(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 202} - - err := r.ReleaseLock(ctx, id, "any-token") - require.ErrorIs(t, err, keg.ErrNotLocked) -} - -func TestMemoryRepo_AcquireLockContention(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 203} - - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - lockCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) - defer cancel() - _, err = r.AcquireLock(lockCtx, id) - require.ErrorIs(t, err, keg.ErrLockTimeout) -} - -func TestMemoryRepo_ForceReleaseLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 204} - - _, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - require.NoError(t, r.ForceReleaseLock(ctx, id)) - - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - require.NotEmpty(t, token) -} - -func TestMemoryRepo_CrossLockDoesNotInterfereWithNodeLock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 205} - - token, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - err = r.WithNodeLock(ctx, id, func(context.Context) error { - return nil - }) - require.NoError(t, err) - - info, err := r.LockStatus(ctx, id) - require.NoError(t, err) - require.Equal(t, token, info.Token) - - require.NoError(t, r.ReleaseLock(ctx, id, token)) -} - -// -- LockInfo.IsStale tests -- - -func TestLockInfo_IsStale(t *testing.T) { - t.Parallel() - now := time.Now() - - info := keg.LockInfo{ - Token: "test-token", - AcquiredAt: now.Add(-10 * time.Minute), - TTLSeconds: 300, // 5 minutes - } - require.True(t, info.IsStale(now)) - - info.AcquiredAt = now.Add(-1 * time.Minute) - require.False(t, info.IsStale(now)) -} - -func TestLockInfo_IsStaleEmptyToken(t *testing.T) { - t.Parallel() - require.True(t, keg.LockInfo{}.IsStale(time.Now())) -} diff --git a/pkg/keg/repo_memory.go b/pkg/keg/repo_memory.go deleted file mode 100644 index 7012aeba..00000000 --- a/pkg/keg/repo_memory.go +++ /dev/null @@ -1,771 +0,0 @@ -package keg - -import ( - "context" - "errors" - "fmt" - "slices" - "sort" - "sync" - "sync/atomic" - "time" - - "github.com/jlrickert/cli-toolkit/toolkit" -) - -// MemoryRepo is an in-memory implementation of Repository intended for -// tests and lightweight tooling that doesn't require persistent storage. -// -// Concurrency / locking: -// -// - MemoryRepo uses an internal sync.RWMutex (mu) to guard all internal maps -// and per-node structures. Readers should use RLock/RUnlock; mutating -// operations use Lock/Unlock. -// - The implementation is safe for concurrent use by multiple goroutines. -// -// Semantics / behavior: -// -// - NodeId entries are created on demand when writing content, meta, items, or -// images. -// - Index files are kept in-memory by name (for example "nodes.tsv") and are -// accessible via WriteIndex/GetIndex. -// - Methods return sentinel or typed errors defined in the package to match the -// Repository contract (for example NewNodeNotFoundError, ErrNotFound). -type MemoryRepo struct { - operationBoundary kegOperationBoundary - operationGeneration atomic.Uint64 - mu sync.RWMutex - // nodes stores per-node data keyed by NodeID. - nodes map[NodeId]*memoryNode - // nodeLocks tracks active per-node lock ownership. Each entry holds a - // "waiters" channel that is closed when the lock is released so that - // goroutines blocked in LockNode wake immediately instead of polling. - nodeLocks map[NodeId]*memoryNodeLockEntry - // indexes stores raw index files by name (for example: "nodes.tsv"). - indexes map[string][]byte - // schemas stores raw schema files by filename (for example: task.schema.yaml). - schemas map[string][]byte - // snapshots stores revision history per node. - snapshots map[NodeId][]memorySnapshotEntry - // config holds the in-memory Config if written. - config *Config - // crossLocks holds cross-process lock state per node. - crossLocks map[NodeId]*memoryLockEntry - - runtime *toolkit.Runtime - - // watchersMu guards the watchers slice for access event emission. - watchersMu sync.Mutex - watchers []*memoryWatch -} - -type memoryNode struct { - content []byte - meta []byte - stats []byte - items map[string][]byte - images map[string][]byte -} - -// memoryNodeLockEntry tracks a held per-node lock and a channel that is -// closed when the lock is released. Waiters block on the channel and wake -// deterministically on release, removing any dependency on wall-clock polling. -type memoryNodeLockEntry struct { - waiters chan struct{} -} - -type memorySnapshotEntry struct { - snapshot Snapshot - content []byte - meta []byte - stats []byte -} - -// NewMemoryRepo constructs a ready-to-use in-memory repository. -func NewMemoryRepo(rt *toolkit.Runtime) *MemoryRepo { - return &MemoryRepo{ - nodes: make(map[NodeId]*memoryNode), - nodeLocks: make(map[NodeId]*memoryNodeLockEntry), - indexes: make(map[string][]byte), - schemas: make(map[string][]byte), - snapshots: make(map[NodeId][]memorySnapshotEntry), - runtime: rt, - } -} - -// ensureNode returns an existing node or creates one if absent. -// Caller must hold r.mu (write lock) when invoking this helper. -func (r *MemoryRepo) ensureNode(id NodeId) *memoryNode { - n, ok := r.nodes[id] - if !ok { - n = &memoryNode{ - items: make(map[string][]byte), - images: make(map[string][]byte), - } - r.nodes[id] = n - } - return n -} - -func (r *MemoryRepo) Name() string { - return "memory" -} - -func (r *MemoryRepo) HasNode(ctx context.Context, id NodeId) (bool, error) { - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - _, ok := r.nodes[id] - return ok, nil -} - -func (r *MemoryRepo) Runtime() *toolkit.Runtime { - if r == nil { - return nil - } - return r.runtime -} - -// Next returns a new NodeID and reserves it by inserting an empty node entry. -// This prevents concurrent callers from receiving the same ID. -func (r *MemoryRepo) Next(ctx context.Context) (NodeId, error) { - r.mu.Lock() - defer r.mu.Unlock() - - // Find the maximum existing NodeID. - max := -1 - for id := range r.nodes { - if int(id.ID) > max { - max = int(id.ID) - } - } - - next := max + 1 - id := NodeId{ID: next} - - // Reserve the ID by creating a placeholder node entry so that - // subsequent calls to Next() will see it and allocate beyond it. - r.ensureNode(id) - - return id, nil -} - -// ReadContent returns the primary content for the given node id. -// -// - If the node does not exist, ErrNodeNotFound is returned. -// - If the node exists but has no content, (nil, nil) is returned. -// - The returned slice is a copy to prevent caller-visible mutation. -func (r *MemoryRepo) ReadContent(ctx context.Context, id NodeId) ([]byte, error) { - r.mu.RLock() - defer r.mu.RUnlock() - n, ok := r.nodes[id] - if !ok { - return nil, ErrNotExist - } - - if n.content == nil { - // NodeContent may legitimately be absent; return nil rather than ErrNotFound. - return nil, nil - } - cp := make([]byte, len(n.content)) - copy(cp, n.content) - r.emitToWatchers(NodeEvent{Kind: NodeEventAccessed, NodeID: id, Field: "content"}) - return cp, nil -} - -// ReadMeta returns the serialized node metadata (usually meta.yaml). -// -// - If the node does not exist, ErrNodeNotFound is returned. -// - If meta is absent, ErrNotFound is returned. -// - The returned bytes are a copy. -func (r *MemoryRepo) ReadMeta(ctx context.Context, id NodeId) ([]byte, error) { - r.mu.RLock() - defer r.mu.RUnlock() - n, ok := r.nodes[id] - if !ok { - return nil, ErrNotExist - } - if n.meta == nil { - return nil, ErrNotExist - } - cp := make([]byte, len(n.meta)) - copy(cp, n.meta) - return cp, nil -} - -// ReadStats returns parsed programmatic stats for a node. -func (r *MemoryRepo) ReadStats(ctx context.Context, id NodeId) (*NodeStats, error) { - r.mu.RLock() - n, ok := r.nodes[id] - if !ok { - r.mu.RUnlock() - return nil, ErrNotExist - } - var raw []byte - fromMeta := n.stats == nil - if fromMeta { - if n.meta == nil { - r.mu.RUnlock() - return nil, ErrNotExist - } - raw = cloneBytes(n.meta) - } else { - raw = cloneBytes(n.stats) - } - r.mu.RUnlock() - stats, err := ParseStats(ctx, raw) - if err != nil { - if fromMeta { - return nil, ErrNotExist - } - return nil, err - } - return stats, nil -} - -func (r *MemoryRepo) NodeFilesExist(ctx context.Context, id NodeId) (bool, bool, error) { - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - - n, ok := r.nodes[id] - if !ok { - return false, false, nil - } - return len(n.meta) > 0, len(n.stats) > 0, nil -} - -// ListIndexes returns the names of stored index files sorted lexicographically. -func (r *MemoryRepo) ListIndexes(ctx context.Context) ([]string, error) { - r.mu.RLock() - defer r.mu.RUnlock() - names := make([]string, 0, len(r.indexes)) - for k := range r.indexes { - names = append(names, k) - } - sort.Strings(names) - return names, nil -} - -// ClearIndexes removes all stored index artifacts. -func (r *MemoryRepo) ClearIndexes(ctx context.Context) error { - r.mu.Lock() - defer r.mu.Unlock() - r.indexes = make(map[string][]byte) - return nil -} - -// ListNodes returns all known NodeIDs sorted in ascending numeric order. -func (r *MemoryRepo) ListNodes(ctx context.Context) ([]NodeId, error) { - r.mu.RLock() - defer r.mu.RUnlock() - ids := make([]NodeId, 0, len(r.nodes)) - for id := range r.nodes { - ids = append(ids, id) - } - slices.SortFunc(ids, func(a, b NodeId) int { - if a.ID < b.ID { - return -1 - } - if a.ID > b.ID { - return 1 - } - return 0 - }) - return ids, nil -} - -// getNode is a small helper that returns the node and a boolean indicating -// presence. It uses RLock/RUnlock internally. -// ListAssets lists asset names for a node and asset kind, sorted lexicographically. -func (r *MemoryRepo) ListAssets(ctx context.Context, id NodeId, kind AssetKind) ([]string, error) { - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - n, ok := r.nodes[id] - if !ok { - return nil, ErrNotExist - } - - var src map[string][]byte - switch kind { - case AssetKindImage: - src = n.images - case AssetKindItem: - src = n.items - default: - return nil, fmt.Errorf("unknown asset kind %q", kind) - } - - names := make([]string, 0, len(src)) - for k := range src { - names = append(names, k) - } - sort.Strings(names) - return names, nil -} - -// WriteContent writes the primary content for the given node id, creating the -// node if necessary. -// -// Note: this implementation stores the provided slice reference in-memory. -// Callers should avoid mutating the provided slice after calling this method. -func (r *MemoryRepo) WriteContent(ctx context.Context, id NodeId, data []byte) error { - r.mu.Lock() - defer r.mu.Unlock() - n := r.ensureNode(id) - n.content = cloneBytes(data) - return nil -} - -// WriteMeta sets the node metadata (meta.yaml bytes), creating the node if needed. -// -// Note: the provided slice is stored as-is in-memory; do not modify it after -// writing. -func (r *MemoryRepo) WriteMeta(ctx context.Context, id NodeId, data []byte) error { - r.mu.Lock() - defer r.mu.Unlock() - n := r.ensureNode(id) - n.meta = cloneBytes(data) - return nil -} - -// WriteStats writes programmatic stats while preserving manually edited meta -// fields. -func (r *MemoryRepo) WriteStats(ctx context.Context, id NodeId, stats *NodeStats) error { - _ = ctx - if stats == nil { - stats = &NodeStats{} - } - - r.mu.Lock() - defer r.mu.Unlock() - - n := r.ensureNode(id) - data, err := stats.ToJSON() - if err != nil { - return err - } - n.stats = data - return nil -} - -// WriteAsset stores a named asset blob for a node. -func (r *MemoryRepo) WriteAsset(ctx context.Context, id NodeId, kind AssetKind, name string, data []byte) error { - if err := validAssetName(name); err != nil { - return err - } - _ = ctx - r.mu.Lock() - defer r.mu.Unlock() - n := r.ensureNode(id) - - switch kind { - case AssetKindImage: - n.images[name] = cloneBytes(data) - case AssetKindItem: - n.items[name] = cloneBytes(data) - default: - return fmt.Errorf("unknown asset kind %q", kind) - } - return nil -} - -// MoveNode renames or moves a node from id to dst. -// -// - If the source node does not exist, ErrNodeNotFound is returned. -// - If the destination already exists, a DestinationExistsError is returned. -// The move is performed by transferring the in-memory node pointer. -func (r *MemoryRepo) MoveNode(ctx context.Context, id NodeId, dst NodeId) error { - r.mu.Lock() - defer r.mu.Unlock() - srcNode, ok := r.nodes[id] - if !ok { - return ErrNotExist - } - if _, exists := r.nodes[dst]; exists { - return ErrDestinationExists - } - // Move (transfer pointer) - r.nodes[dst] = srcNode - delete(r.nodes, id) - if snaps, ok := r.snapshots[id]; ok { - r.snapshots[dst] = snaps - delete(r.snapshots, id) - } - return nil -} - -// GetIndex reads a stored index by name. If not present, ErrNotFound is returned. -// The returned bytes are a copy. -func (r *MemoryRepo) GetIndex(ctx context.Context, name string) ([]byte, error) { - r.mu.RLock() - defer r.mu.RUnlock() - b, ok := r.indexes[name] - if !ok { - return nil, ErrNotExist - } - cp := make([]byte, len(b)) - copy(cp, b) - return cp, nil -} - -// WriteIndex writes or replaces an in-memory index file. -func (r *MemoryRepo) WriteIndex(ctx context.Context, name string, data []byte) error { - r.mu.Lock() - defer r.mu.Unlock() - r.indexes[name] = cloneBytes(data) - return nil -} - -// ClearDex removes all stored index artifacts. -func (r *MemoryRepo) ClearDex() error { - r.mu.Lock() - defer r.mu.Unlock() - r.indexes = make(map[string][]byte) - return nil -} - -// DeleteNode removes the node and all associated content/metadata/items. -// If the node does not exist, NewNodeNotFoundError is returned. -func (r *MemoryRepo) DeleteNode(ctx context.Context, id NodeId) error { - r.mu.Lock() - defer r.mu.Unlock() - if _, ok := r.nodes[id]; !ok { - return ErrNotExist - } - delete(r.nodes, id) - delete(r.snapshots, id) - return nil -} - -// DeleteAsset removes an asset by name for a node. -func (r *MemoryRepo) DeleteAsset(ctx context.Context, id NodeId, kind AssetKind, name string) error { - if err := validAssetName(name); err != nil { - return err - } - _ = ctx - r.mu.Lock() - defer r.mu.Unlock() - n, ok := r.nodes[id] - if !ok { - return ErrNotExist - } - - switch kind { - case AssetKindImage: - if _, ok := n.images[name]; !ok { - return ErrNotExist - } - delete(n.images, name) - case AssetKindItem: - if _, ok := n.items[name]; !ok { - return ErrNotExist - } - delete(n.items, name) - default: - return fmt.Errorf("unknown asset kind %q", kind) - } - return nil -} - -func (r *MemoryRepo) ListFiles(ctx context.Context, id NodeId) ([]string, error) { - return r.ListAssets(ctx, id, AssetKindItem) -} - -func (r *MemoryRepo) ListImages(ctx context.Context, id NodeId) ([]string, error) { - return r.ListAssets(ctx, id, AssetKindImage) -} - -func (r *MemoryRepo) ReadFile(ctx context.Context, id NodeId, name string) ([]byte, error) { - if err := validAssetName(name); err != nil { - return nil, err - } - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - n, ok := r.nodes[id] - if !ok { - return nil, ErrNotExist - } - data, exists := n.items[name] - if !exists { - return nil, ErrNotExist - } - cp := make([]byte, len(data)) - copy(cp, data) - return cp, nil -} - -func (r *MemoryRepo) ReadImage(ctx context.Context, id NodeId, name string) ([]byte, error) { - if err := validAssetName(name); err != nil { - return nil, err - } - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - n, ok := r.nodes[id] - if !ok { - return nil, ErrNotExist - } - data, exists := n.images[name] - if !exists { - return nil, ErrNotExist - } - cp := make([]byte, len(data)) - copy(cp, data) - return cp, nil -} - -func (r *MemoryRepo) WriteImage(ctx context.Context, id NodeId, name string, data []byte) error { - return r.WriteAsset(ctx, id, AssetKindImage, name, data) -} - -func (r *MemoryRepo) WriteFile(ctx context.Context, id NodeId, name string, data []byte) error { - return r.WriteAsset(ctx, id, AssetKindItem, name, data) -} - -func (r *MemoryRepo) DeleteImage(ctx context.Context, id NodeId, name string) error { - return r.DeleteAsset(ctx, id, AssetKindImage, name) -} - -func (r *MemoryRepo) DeleteFile(ctx context.Context, id NodeId, name string) error { - return r.DeleteAsset(ctx, id, AssetKindItem, name) -} - -// ReadConfig returns the repository-level config previously written with -// WriteConfig. If no config has been written, ErrNotFound is returned. -// A copy of the stored Config is returned to avoid external mutation. -func (r *MemoryRepo) ReadConfig(ctx context.Context) (*Config, error) { - r.mu.RLock() - defer r.mu.RUnlock() - if r.config == nil { - return nil, ErrNotExist - } - c := cloneConfig(r.config) - c.materializeSystemIndexes() - return c, nil -} - -// WriteConfig stores the provided Config in-memory. A copy of the value is kept. -func (r *MemoryRepo) WriteConfig(ctx context.Context, config *Config) error { - persisted, err := config.persistedCopy() - if err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - r.config = cloneConfig(persisted) - return nil -} - -func cloneConfig(src *Config) *Config { - if src == nil { - return nil - } - out := *src - out.Links = slices.Clone(src.Links) - out.Indexes = slices.Clone(src.Indexes) - if src.Snapshots != nil { - v := *src.Snapshots - out.Snapshots = &v - } - if src.SchemaPolicy != nil { - v := *src.SchemaPolicy - out.SchemaPolicy = &v - } - return &out -} - -func (r *MemoryRepo) ListSchemas(ctx context.Context) ([]string, error) { - _ = ctx - r.mu.RLock() - defer r.mu.RUnlock() - return schemaTypeFilesFromMap(r.schemas), nil -} - -func (r *MemoryRepo) ReadSchema(ctx context.Context, typeName string) ([]byte, error) { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return nil, err - } - r.mu.RLock() - defer r.mu.RUnlock() - data, ok := r.schemas[filename] - if !ok { - return nil, ErrNotExist - } - return cloneBytes(data), nil -} - -func (r *MemoryRepo) WriteSchema(ctx context.Context, typeName string, data []byte) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - if r.schemas == nil { - r.schemas = make(map[string][]byte) - } - r.schemas[filename] = cloneBytes(data) - return nil -} - -func (r *MemoryRepo) CreateSchema(ctx context.Context, typeName string, data []byte) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - if r.schemas == nil { - r.schemas = make(map[string][]byte) - } - if _, exists := r.schemas[filename]; exists { - return ErrExist - } - r.schemas[filename] = cloneBytes(data) - return nil -} - -func (r *MemoryRepo) DeleteSchema(ctx context.Context, typeName string) error { - _ = ctx - filename, err := SchemaFilename(typeName) - if err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - if _, ok := r.schemas[filename]; !ok { - return ErrNotExist - } - delete(r.schemas, filename) - return nil -} - -// ClearNodeLock removes an active per-node lock marker and wakes any waiters -// that were blocked on it. -func (r *MemoryRepo) ClearNodeLock(ctx context.Context, id NodeId) error { - _ = ctx - r.mu.Lock() - defer r.mu.Unlock() - key := lockNodeKey(id) - if entry, held := r.nodeLocks[key]; held { - delete(r.nodeLocks, key) - close(entry.waiters) - } - return nil -} - -// LockNode attempts to acquire a per-node lock, blocking until the lock is -// released by the current holder or the context is cancelled. On success it -// returns an unlock function which the caller MUST call to release the lock. -// -// Behavior notes: -// -// - The retryInterval argument is retained for API compatibility but is no -// longer used. Waiters are woken directly by the releasing goroutine via a -// per-entry channel, eliminating the need for wall-clock polling and -// making contention handling deterministic under a frozen test clock. -// - If ctx is cancelled while waiting, ErrLockTimeout is returned. -func (r *MemoryRepo) LockNode(ctx context.Context, id NodeId, retryInterval time.Duration) (func() error, error) { - _ = retryInterval - key := lockNodeKey(id) - - for { - r.mu.Lock() - if _, locked := r.nodeLocks[key]; !locked { - entry := &memoryNodeLockEntry{waiters: make(chan struct{})} - r.nodeLocks[key] = entry - r.mu.Unlock() - - unlock := func() error { - r.mu.Lock() - defer r.mu.Unlock() - // Only delete if we still own the entry. ForceReleaseLock or - // ClearNodeLock may have removed it concurrently, in which - // case waiters have already been signaled. - if current, held := r.nodeLocks[key]; held && current == entry { - delete(r.nodeLocks, key) - close(entry.waiters) - } - return nil - } - return unlock, nil - } - // Another goroutine holds the lock. Grab a reference to its waiters - // channel while still under r.mu so we cannot miss the close signal, - // then release r.mu before blocking. - waiters := r.nodeLocks[key].waiters - r.mu.Unlock() - - select { - case <-ctx.Done(): - return nil, fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-waiters: - // Holder released; retry acquisition. Another waiter may win - // the race, in which case we loop again. - } - } -} - -// WithNodeLock executes fn while holding an exclusive lock for node id. -func (r *MemoryRepo) WithNodeLock(ctx context.Context, id NodeId, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - if contextHasNodeLock(ctx, id) { - return fn(ctx) - } - - unlock, err := r.LockNode(ctx, id, 100*time.Millisecond) - if err != nil { - if errors.Is(err, ErrLockTimeout) { - return err - } - return errors.Join(ErrLock, err) - } - - lockedCtx := contextWithNodeLock(ctx, id) - runErr := fn(lockedCtx) - unlockErr := unlock() - return errors.Join(runErr, unlockErr) -} - -// registerWatcher adds a watch subscriber to the active set for access event emission. -func (r *MemoryRepo) registerWatcher(w *memoryWatch) { - r.watchersMu.Lock() - r.watchers = append(r.watchers, w) - r.watchersMu.Unlock() -} - -// unregisterWatcher removes a watch subscriber from the active set. -func (r *MemoryRepo) unregisterWatcher(w *memoryWatch) { - r.watchersMu.Lock() - defer r.watchersMu.Unlock() - for i, active := range r.watchers { - if active == w { - r.watchers = append(r.watchers[:i], r.watchers[i+1:]...) - return - } - } -} - -// emitToWatchers broadcasts a NodeEvent to all active watch subscribers. -func (r *MemoryRepo) emitToWatchers(ev NodeEvent) { - r.watchersMu.Lock() - defer r.watchersMu.Unlock() - for _, w := range r.watchers { - w.emit(ev) - } -} - -// Ensure MemoryRepo implements Repository at compile time. -var _ Repository = (*MemoryRepo)(nil) -var _ RepositoryFiles = (*MemoryRepo)(nil) -var _ RepositoryImages = (*MemoryRepo)(nil) -var _ RepositorySchemas = (*MemoryRepo)(nil) diff --git a/pkg/keg/repo_memory_events.go b/pkg/keg/repo_memory_events.go deleted file mode 100644 index 13eba48e..00000000 --- a/pkg/keg/repo_memory_events.go +++ /dev/null @@ -1,58 +0,0 @@ -package keg - -import ( - "context" -) - -// memoryWatch is the per-Watch subscriber handle for MemoryRepo live events. -// MemoryRepo has no external change source, so events arrive exclusively via -// Emit (access bumps from the repo itself, or test code simulating changes). -type memoryWatch struct { - ch chan NodeEvent - ids map[NodeId]struct{} // empty means all nodes -} - -// Watch implements RepositoryEvents for MemoryRepo. Events are delivered on -// the returned channel until ctx is cancelled, at which point the channel is -// closed. -func (r *MemoryRepo) Watch(ctx context.Context, ids ...NodeId) (<-chan NodeEvent, error) { - idSet := make(map[NodeId]struct{}, len(ids)) - for _, id := range ids { - idSet[id] = struct{}{} - } - w := &memoryWatch{ch: make(chan NodeEvent, 16), ids: idSet} - r.registerWatcher(w) - - // Cleanup: unregister first (Emit holds watchersMu, so no send can race - // the close), then close the channel. - go func() { - <-ctx.Done() - r.unregisterWatcher(w) - close(w.ch) - }() - - return w.ch, nil -} - -// Emit implements RepositoryEvents. It broadcasts a programmatic event to -// all active Watch subscribers whose filters match. Test code uses this to -// simulate repository changes. -func (r *MemoryRepo) Emit(ev NodeEvent) { - r.emitToWatchers(ev) -} - -// emit delivers an event to this subscriber. Called by -// MemoryRepo.emitToWatchers under watchersMu; sends are non-blocking so a -// slow consumer cannot deadlock the emitter. -func (w *memoryWatch) emit(ev NodeEvent) { - _, match := w.ids[ev.NodeID] - if len(w.ids) == 0 || match { - select { - case w.ch <- ev: - default: - // Drop event if subscriber is slow. - } - } -} - -var _ RepositoryEvents = (*MemoryRepo)(nil) diff --git a/pkg/keg/repo_memory_lock.go b/pkg/keg/repo_memory_lock.go deleted file mode 100644 index 6513861f..00000000 --- a/pkg/keg/repo_memory_lock.go +++ /dev/null @@ -1,126 +0,0 @@ -package keg - -import ( - "context" - "fmt" - "time" -) - -// memoryLockEntry holds process-local advisory lock state for a single node. -// It does not survive a process restart or coordinate separate processes; -// production backends must document their own deployment scope. The -// waiters channel is closed when the entry is released (or force-released), -// so blocked AcquireLock callers wake deterministically without having to -// poll on a wall-clock ticker. -type memoryLockEntry struct { - info LockInfo - waiters chan struct{} -} - -// AcquireLock implements RepositoryLock. -func (r *MemoryRepo) AcquireLock(ctx context.Context, id NodeId) (LockToken, error) { - key := lockNodeKey(id) - for { - r.mu.Lock() - entry, held := r.crossLocks[key] - if !held || entry.info.IsStale(r.runtime.Clock().Now()) { - token := generateLockToken() - info := LockInfo{ - Token: token, - AcquiredAt: r.runtime.Clock().Now(), - TTLSeconds: int(DefaultLockTTL / time.Second), - Holder: "memory-repo", - } - if r.crossLocks == nil { - r.crossLocks = make(map[NodeId]*memoryLockEntry) - } - // If we are taking over a stale entry, wake any waiters that - // were parked on its channel before overwriting the slot. - if held && entry != nil && entry.waiters != nil { - close(entry.waiters) - } - r.crossLocks[key] = &memoryLockEntry{ - info: info, - waiters: make(chan struct{}), - } - r.mu.Unlock() - return token, nil - } - // Capture the waiters channel under the lock so we cannot miss - // the close that will be performed by a concurrent release. - waiters := entry.waiters - expiresIn := entry.info.expiresAt().Sub(r.runtime.Clock().Now()) - r.mu.Unlock() - - if waiters == nil { - // Defensive fallback: entry was constructed without a waiter - // channel. Yield briefly via context to avoid a tight loop. - select { - case <-ctx.Done(): - return "", fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - default: - } - continue - } - select { - case <-ctx.Done(): - return "", fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-waiters: - // Holder released; retry acquisition. - case <-r.runtime.SchedulingClock().After(expiresIn): - // The advisory lease reached its TTL; retry and replace it if stale. - } - } -} - -// ReleaseLock implements RepositoryLock. -func (r *MemoryRepo) ReleaseLock(ctx context.Context, id NodeId, token LockToken) error { - key := lockNodeKey(id) - r.mu.Lock() - defer r.mu.Unlock() - - entry, held := r.crossLocks[key] - if !held { - return ErrNotLocked - } - if entry.info.Token != token { - return fmt.Errorf("%w: lock held by %q", ErrLockTokenMismatch, entry.info.Holder) - } - delete(r.crossLocks, key) - if entry.waiters != nil { - close(entry.waiters) - } - return nil -} - -// LockStatus implements RepositoryLock. -func (r *MemoryRepo) LockStatus(ctx context.Context, id NodeId) (LockInfo, error) { - key := lockNodeKey(id) - r.mu.RLock() - defer r.mu.RUnlock() - - entry, held := r.crossLocks[key] - if !held { - return LockInfo{}, nil - } - if entry.info.IsStale(r.runtime.Clock().Now()) { - return LockInfo{}, nil - } - return entry.info, nil -} - -// ForceReleaseLock implements RepositoryLock. -func (r *MemoryRepo) ForceReleaseLock(ctx context.Context, id NodeId) error { - key := lockNodeKey(id) - r.mu.Lock() - defer r.mu.Unlock() - if entry, held := r.crossLocks[key]; held { - delete(r.crossLocks, key) - if entry.waiters != nil { - close(entry.waiters) - } - } - return nil -} - -var _ RepositoryLock = (*MemoryRepo)(nil) diff --git a/pkg/keg/repo_memory_snapshots.go b/pkg/keg/repo_memory_snapshots.go deleted file mode 100644 index 136aa939..00000000 --- a/pkg/keg/repo_memory_snapshots.go +++ /dev/null @@ -1,162 +0,0 @@ -package keg - -import ( - "context" - "fmt" -) - -func (r *MemoryRepo) AppendSnapshot(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { - r.mu.Lock() - defer r.mu.Unlock() - return r.appendSnapshotLocked(ctx, id, in) -} - -func (r *MemoryRepo) appendSnapshotLocked(ctx context.Context, id NodeId, in SnapshotWrite) (Snapshot, error) { - if _, ok := r.nodes[id]; !ok { - return Snapshot{}, ErrNotExist - } - - entries := r.snapshots[id] - var parent RevisionID - if len(entries) > 0 { - parent = entries[len(entries)-1].snapshot.ID - } - if in.ExpectedParent != parent { - return Snapshot{}, fmt.Errorf("expected parent %d, got %d: %w", in.ExpectedParent, parent, ErrConflict) - } - - content, meta, statsBytes, err := normalizeSnapshotWrite(ctx, r.runtime, in) - if err != nil { - return Snapshot{}, err - } - contentHash, metaHash, statsHash := snapshotWriteHashes(r.runtime, content, meta, statsBytes) - createdAt := in.CreatedAt - if createdAt.IsZero() { - createdAt = r.runtime.Clock().Now() - } - - snapshot := Snapshot{ - ID: parent + 1, - Node: id, - Parent: parent, - CreatedAt: createdAt, - Message: in.Message, - ContentHash: contentHash, - MetaHash: metaHash, - StatsHash: statsHash, - IsCheckpoint: true, - } - r.snapshots[id] = append(entries, memorySnapshotEntry{ - snapshot: snapshot, - content: content, - meta: meta, - stats: statsBytes, - }) - return snapshot, nil -} - -func (r *MemoryRepo) GetSnapshot(ctx context.Context, id NodeId, rev RevisionID, opts SnapshotReadOptions) (Snapshot, []byte, []byte, *NodeStats, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - entry, err := r.snapshotEntryLocked(id, rev) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - snap := entry.snapshot - - var content []byte - if opts.ResolveContent { - content = cloneBytes(entry.content) - } - meta := cloneBytes(entry.meta) - stats, err := snapshotStatsFromBytes(ctx, entry.stats) - if err != nil { - return Snapshot{}, nil, nil, nil, err - } - return snap, content, meta, stats, nil -} - -func (r *MemoryRepo) ListSnapshots(ctx context.Context, id NodeId) ([]Snapshot, error) { - r.mu.RLock() - defer r.mu.RUnlock() - if _, ok := r.nodes[id]; !ok { - return nil, ErrNotExist - } - - entries := r.snapshots[id] - out := make([]Snapshot, 0, len(entries)) - for _, entry := range entries { - out = append(out, entry.snapshot) - } - return out, nil -} - -func (r *MemoryRepo) ReadContentAt(ctx context.Context, id NodeId, rev RevisionID) ([]byte, error) { - r.mu.RLock() - defer r.mu.RUnlock() - entry, err := r.snapshotEntryLocked(id, rev) - if err != nil { - return nil, err - } - return cloneBytes(entry.content), nil -} - -func (r *MemoryRepo) RestoreSnapshot(ctx context.Context, id NodeId, rev RevisionID, createRestoreSnapshot bool) error { - r.mu.Lock() - defer r.mu.Unlock() - - entries := r.snapshots[id] - var parent RevisionID - if len(entries) > 0 { - parent = entries[len(entries)-1].snapshot.ID - } - - entry, err := r.snapshotEntryLocked(id, rev) - if err != nil { - return err - } - node, ok := r.nodes[id] - if !ok { - return ErrNotExist - } - - node.content = cloneBytes(entry.content) - node.meta = cloneBytes(entry.meta) - node.stats = cloneBytes(entry.stats) - - if !createRestoreSnapshot { - return nil - } - - stats, err := snapshotStatsFromBytes(ctx, entry.stats) - if err != nil { - return err - } - _, err = r.appendSnapshotLocked(ctx, id, SnapshotWrite{ - ExpectedParent: parent, - Message: fmt.Sprintf("restore from rev %d", rev), - Meta: cloneBytes(entry.meta), - Stats: stats, - Content: SnapshotContentWrite{ - Kind: SnapshotContentKindFull, - Data: cloneBytes(entry.content), - Hash: entry.snapshot.ContentHash, - }, - }) - return err -} - -func (r *MemoryRepo) snapshotEntryLocked(id NodeId, rev RevisionID) (memorySnapshotEntry, error) { - if _, ok := r.nodes[id]; !ok { - return memorySnapshotEntry{}, ErrNotExist - } - for _, entry := range r.snapshots[id] { - if entry.snapshot.ID == rev { - return entry, nil - } - } - return memorySnapshotEntry{}, ErrNotExist -} - -var _ RepositorySnapshots = (*MemoryRepo)(nil) diff --git a/pkg/keg/repo_memory_test.go b/pkg/keg/repo_memory_test.go deleted file mode 100644 index b35e5b83..00000000 --- a/pkg/keg/repo_memory_test.go +++ /dev/null @@ -1,575 +0,0 @@ -package keg_test - -import ( - "bytes" - "context" - "fmt" - "sync" - "testing" - "time" - - toolkitclock "github.com/jlrickert/cli-toolkit/clock" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -type controlledExpiryClock struct { - toolkitclock.OsClock - mu sync.Mutex - now time.Time - registered chan struct{} - once sync.Once - timer chan time.Time -} - -func newControlledExpiryClock(now time.Time) *controlledExpiryClock { - return &controlledExpiryClock{now: now, registered: make(chan struct{}), timer: make(chan time.Time, 1)} -} - -func (c *controlledExpiryClock) Now() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.now -} - -func (c *controlledExpiryClock) After(time.Duration) <-chan time.Time { - c.once.Do(func() { close(c.registered) }) - return c.timer -} - -func (c *controlledExpiryClock) advance(d time.Duration) { - c.mu.Lock() - c.now = c.now.Add(d) - now := c.now - c.mu.Unlock() - c.timer <- now -} - -func TestMemoryRepoAcquireLockWakesAtExpiry(t *testing.T) { - fx := NewSandbox(t) - clock := newControlledExpiryClock(time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)) - require.NoError(t, fx.Runtime().SetClock(clock)) - repo := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 17} - first, err := repo.AcquireLock(t.Context(), id) - require.NoError(t, err) - - acquired := make(chan keg.LockToken, 1) - errs := make(chan error, 1) - go func() { - token, err := repo.AcquireLock(t.Context(), id) - if err != nil { - errs <- err - return - } - acquired <- token - }() - <-clock.registered - clock.advance(keg.DefaultLockTTL) - - select { - case err := <-errs: - require.NoError(t, err) - case token := <-acquired: - require.NotEmpty(t, token) - require.NotEqual(t, first, token) - case <-t.Context().Done(): - t.Fatal("waiter did not acquire expired advisory lock") - } -} - -func TestMemoryRepoConcurrentReadWriteClones(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - id := keg.NodeId{ID: 91} - - writeAll := func(i int) error { - now := time.Date(2026, 7, 18, 12, 0, i%60, 0, time.UTC) - stats := keg.NewStats(now) - stats.SetHash(fmt.Sprintf("hash-%d", i), &now) - stats.SetLinks([]keg.NodeId{{ID: i % 7}}) - cfg := &keg.Config{ - Kegv: keg.ConfigV2VersionString, - Title: fmt.Sprintf("title-%d", i), - Links: []keg.LinkEntry{{Alias: "source", URL: fmt.Sprintf("https://example.test/%d", i)}}, - Indexes: []keg.IndexEntry{{File: "custom.tsv", Summary: fmt.Sprintf("summary-%d", i)}}, - Snapshots: &keg.SnapshotConfig{Mode: keg.SnapshotModeAuto, IdleAfter: "1h"}, - SchemaPolicy: &keg.SchemaPolicy{Human: keg.ValidationModeWarn}, - } - for _, call := range []func() error{ - func() error { return r.WriteContent(ctx, id, []byte(fmt.Sprintf("# title %d\n", i))) }, - func() error { return r.WriteMeta(ctx, id, []byte(fmt.Sprintf("tags: [tag-%d]\n", i))) }, - func() error { return r.WriteStats(ctx, id, stats) }, - func() error { return r.WriteFile(ctx, id, "item.txt", []byte(fmt.Sprintf("item-%d", i))) }, - func() error { return r.WriteImage(ctx, id, "image.png", []byte(fmt.Sprintf("image-%d", i))) }, - func() error { return r.WriteIndex(ctx, "nodes.tsv", []byte(fmt.Sprintf("index-%d", i))) }, - func() error { return r.WriteConfig(ctx, cfg) }, - func() error { - return r.WriteSchema(ctx, "task", []byte(fmt.Sprintf("type: task\nsummary: schema-%d\n", i))) - }, - } { - if err := call(); err != nil { - return err - } - } - return nil - } - require.NoError(t, writeAll(0)) - - readAll := func() error { - content, err := r.ReadContent(ctx, id) - if err != nil { - return err - } - meta, err := r.ReadMeta(ctx, id) - if err != nil { - return err - } - stats, err := r.ReadStats(ctx, id) - if err != nil { - return err - } - item, err := r.ReadFile(ctx, id, "item.txt") - if err != nil { - return err - } - image, err := r.ReadImage(ctx, id, "image.png") - if err != nil { - return err - } - index, err := r.GetIndex(ctx, "nodes.tsv") - if err != nil { - return err - } - cfg, err := r.ReadConfig(ctx) - if err != nil { - return err - } - schema, err := r.ReadSchema(ctx, "task") - if err != nil { - return err - } - - for _, data := range [][]byte{content, meta, item, image, index, schema} { - if len(data) > 0 { - data[0] ^= 0xff - } - } - stats.SetLinks([]keg.NodeId{{ID: 999}}) - if len(cfg.Links) > 0 { - cfg.Links[0].Alias = "mutated" - } - if len(cfg.Indexes) > 0 { - cfg.Indexes[0].Summary = "mutated" - } - if cfg.Snapshots != nil { - cfg.Snapshots.Mode = keg.SnapshotModeOff - } - if cfg.SchemaPolicy != nil { - cfg.SchemaPolicy.Human = keg.ValidationModeBlock - } - return nil - } - - errCh := make(chan error, 2) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for i := 1; i <= 500; i++ { - if err := writeAll(i); err != nil { - errCh <- err - return - } - } - }() - go func() { - defer wg.Done() - for i := 0; i < 500; i++ { - if err := readAll(); err != nil { - errCh <- err - return - } - } - }() - wg.Wait() - close(errCh) - for err := range errCh { - require.NoError(t, err) - } - require.NoError(t, readAll()) -} - -func TestMemoryRepo_WriteReadMetaAndContent(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - - id := keg.NodeId{ID: 10} - content := []byte("# hello\n") - meta := []byte("title: test\nupdated: 2025-08-11 00:00:00Z\n") - - require.NoError(t, r.WriteContent(ctx, id, content)) - require.NoError(t, r.WriteMeta(ctx, id, meta)) - - gotMeta, err := r.ReadMeta(ctx, id) - require.NoError(t, err) - require.Equal(t, meta, gotMeta, "meta bytes should match") - - gotContent, err := r.ReadContent(ctx, id) - require.NoError(t, err) - require.Equal(t, content, gotContent, "content bytes should match") - - ids, err := r.ListNodes(ctx) - require.NoError(t, err) - require.Contains(t, ids, id, "expected ListNodes to contain written id") -} - -func TestMemoryRepo_HasNode(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - id := keg.NodeId{ID: 10} - - exists, err := r.HasNode(ctx, id) - require.NoError(t, err) - require.False(t, exists) - - require.NoError(t, r.WriteContent(ctx, id, []byte("hello"))) - - exists, err = r.HasNode(ctx, id) - require.NoError(t, err) - require.True(t, exists) -} - -func TestMemoryRepo_WriteReadStats(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - id := keg.NodeId{ID: 77} - - require.NoError(t, r.WriteMeta(ctx, id, []byte("title: keep-me\nfoo: bar\n"))) - - now := time.Date(2026, 2, 14, 12, 0, 0, 0, time.UTC) - stats := keg.NewStats(now) - stats.SetHash("h1", &now) - stats.SetLead("lead text") - stats.SetLinks([]keg.NodeId{{ID: 1}, {ID: 2}}) - stats.SetAccessed(now) - - require.NoError(t, r.WriteStats(ctx, id, stats)) - - gotStats, err := r.ReadStats(ctx, id) - require.NoError(t, err) - require.Equal(t, "h1", gotStats.Hash()) - require.Equal(t, "lead text", gotStats.Lead()) - require.Len(t, gotStats.Links(), 2) - - gotMeta, err := r.ReadMeta(ctx, id) - require.NoError(t, err) - require.Contains(t, string(gotMeta), "title: keep-me") - require.Contains(t, string(gotMeta), "foo: bar") - require.NotContains(t, string(gotMeta), "hash:") -} - -func TestMemoryRepo_ReadMissingReturnsNotFound(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - - missing := keg.NodeId{ID: 9999} - - _, err := r.ReadContent(ctx, missing) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrNotExist) -} - -func TestMemoryRepo_WriteAndListIndexes_GetIndex(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - - name := "dex/nodes.tsv" - data := []byte("1\t2025-08-11 00:00:00Z\tTitle\n") - require.NoError(t, r.WriteIndex(ctx, name, data)) - - got, err := r.GetIndex(ctx, name) - require.NoError(t, err) - require.Equal(t, data, got, "index data mismatch") - - list, err := r.ListIndexes(ctx) - require.NoError(t, err) - require.Contains(t, list, name, "expected ListIndexes to include written index name") -} - -func TestMemoryRepo_AssetsAPI(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - id := keg.NodeId{ID: 41} - - require.NoError(t, r.WriteAsset(ctx, id, keg.AssetKindImage, "a.png", []byte("png"))) - require.NoError(t, r.WriteAsset(ctx, id, keg.AssetKindItem, "doc.txt", []byte("txt"))) - - images, err := r.ListAssets(ctx, id, keg.AssetKindImage) - require.NoError(t, err) - require.Equal(t, []string{"a.png"}, images) - - items, err := r.ListAssets(ctx, id, keg.AssetKindItem) - require.NoError(t, err) - require.Equal(t, []string{"doc.txt"}, items) - - require.NoError(t, r.DeleteAsset(ctx, id, keg.AssetKindItem, "doc.txt")) - items, err = r.ListAssets(ctx, id, keg.AssetKindItem) - require.NoError(t, err) - require.Empty(t, items) -} - -func TestMemoryRepo_MoveNodeAndDestinationExists(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - - src := keg.NodeId{ID: 20} - dst := keg.NodeId{ID: 30} - other := keg.NodeId{ID: 31} - content := []byte("content") - - // prepare src node - require.NoError(t, r.WriteContent(ctx, src, content)) - require.NoError(t, r.WriteMeta(ctx, src, []byte("title: src\n"))) - - // moving to an unused dst should succeed - require.NoError(t, r.MoveNode(ctx, src, dst)) - - // src should no longer exist - _, err := r.ReadContent(ctx, src) - require.ErrorIs(t, err, keg.ErrNotExist) - - // dst should exist with same content - got, err := r.ReadContent(ctx, dst) - require.NoError(t, err) - require.Equal(t, content, got, "moved content mismatch") - - // create another node at 'other' and attempt to move dst -> other to force destination-exists - require.NoError(t, r.WriteContent(ctx, other, []byte("x"))) - require.NoError(t, r.WriteMeta(ctx, other, []byte("title: other\n"))) - - err = r.MoveNode(ctx, dst, other) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrDestinationExists) -} - -func TestMemoryRepo_NextProducesIncreasingIDs(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - r := keg.NewMemoryRepo(fx.Runtime()) - ctx := fx.Context() - - // Obtain the next available ID. - first, err := r.Next(ctx) - require.NoError(t, err) - - // Allocate the first ID by writing content to it so subsequent Next() reflects the updated state. - require.NoError(t, r.WriteContent(ctx, first, []byte("first"))) - - // Now Next should return a strictly larger id. - second, err := r.Next(ctx) - require.NoError(t, err) - require.Greater(t, int(second.ID), int(first.ID), "expected second Next() > first Next()") - - // Write content at the second id and ensure the node exists afterwards. - content := []byte("next-test") - require.NoError(t, r.WriteContent(ctx, second, content)) - got, err := r.ReadContent(ctx, second) - require.NoError(t, err) - require.Equal(t, content, got, "content mismatch for Next id") - - // Ensure ListNodes includes the written IDs. - ids, err := r.ListNodes(ctx) - require.NoError(t, err) - require.Contains(t, ids, first) - require.Contains(t, ids, second) - - // sanity: ensure bytes.Equal works as expected for content comparisons used earlier - require.True(t, bytes.Equal(content, got)) -} - -func TestMemoryRepo_WithNodeLockTimeout(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 55} - - locked := make(chan struct{}) - release := make(chan struct{}) - done := make(chan error, 1) - - go func() { - done <- r.WithNodeLock(ctx, id, func(context.Context) error { - close(locked) - <-release - return nil - }) - }() - - <-locked - - lockCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - err := r.WithNodeLock(lockCtx, id, func(context.Context) error { - return nil - }) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrLockTimeout) - - close(release) - require.NoError(t, <-done) -} - -func TestMemoryRepo_WithNodeLockReentrant(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 56} - - err := r.WithNodeLock(ctx, id, func(lockCtx context.Context) error { - return r.WithNodeLock(lockCtx, id, func(context.Context) error { - return nil - }) - }) - require.NoError(t, err) -} - -// TestMemoryRepo_WithNodeLockContentionWakesWithoutWallClock verifies that a -// goroutine blocked on WithNodeLock acquires the lock as soon as the current -// holder releases it, with no dependence on a wall-clock retry interval. The -// test drives contention on a frozen sandbox clock so any reliance on -// time.Ticker / time.After would deadlock the waiter. -func TestMemoryRepo_WithNodeLockContentionWakesWithoutWallClock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 57} - - holderEntered := make(chan struct{}) - releaseHolder := make(chan struct{}) - holderDone := make(chan error, 1) - - go func() { - holderDone <- r.WithNodeLock(ctx, id, func(context.Context) error { - close(holderEntered) - <-releaseHolder - return nil - }) - }() - - // Wait until the holder has acquired the lock before starting the waiter. - select { - case <-holderEntered: - case <-time.After(5 * time.Second): - t.Fatal("holder never acquired the lock") - } - - waiterAcquired := make(chan struct{}) - waiterDone := make(chan error, 1) - go func() { - waiterDone <- r.WithNodeLock(ctx, id, func(context.Context) error { - close(waiterAcquired) - return nil - }) - }() - - // Give the waiter a moment to park on the release signal. This is purely - // a scheduler yield; the waiter must not be able to acquire the lock - // until the holder releases it. - select { - case <-waiterAcquired: - t.Fatal("waiter acquired lock while holder still held it") - case <-time.After(50 * time.Millisecond): - } - - // Release the holder. The waiter should wake immediately via the - // channel broadcast, with no dependence on a polling retry interval. - close(releaseHolder) - - select { - case <-waiterAcquired: - case <-time.After(5 * time.Second): - t.Fatal("waiter did not wake after holder released the lock") - } - - require.NoError(t, <-holderDone) - require.NoError(t, <-waiterDone) -} - -// TestMemoryRepo_AcquireLockContentionWakesWithoutWallClock is the cross-lock -// analog of the WithNodeLock contention test above: a waiter blocked in -// AcquireLock must wake as soon as the current holder calls ReleaseLock, with -// no wall-clock retry interval involved. -func TestMemoryRepo_AcquireLockContentionWakesWithoutWallClock(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - ctx := fx.Context() - - r := keg.NewMemoryRepo(fx.Runtime()) - id := keg.NodeId{ID: 58} - - firstToken, err := r.AcquireLock(ctx, id) - require.NoError(t, err) - - waiterAcquired := make(chan keg.LockToken, 1) - waiterErr := make(chan error, 1) - go func() { - tok, err := r.AcquireLock(ctx, id) - if err != nil { - waiterErr <- err - return - } - waiterAcquired <- tok - }() - - // Waiter must not acquire while the first holder is still active. - select { - case tok := <-waiterAcquired: - t.Fatalf("waiter acquired lock while first holder still held it: %q", tok) - case err := <-waiterErr: - t.Fatalf("waiter errored before release: %v", err) - case <-time.After(50 * time.Millisecond): - } - - require.NoError(t, r.ReleaseLock(ctx, id, firstToken)) - - select { - case tok := <-waiterAcquired: - require.NotEqual(t, firstToken, tok, "waiter should receive a fresh token") - require.NoError(t, r.ReleaseLock(ctx, id, tok)) - case err := <-waiterErr: - t.Fatalf("waiter errored after release: %v", err) - case <-time.After(5 * time.Second): - t.Fatal("waiter did not wake after first holder released") - } -} diff --git a/pkg/keg/repo_operation.go b/pkg/keg/repo_operation.go deleted file mode 100644 index 29306f0b..00000000 --- a/pkg/keg/repo_operation.go +++ /dev/null @@ -1,200 +0,0 @@ -package keg - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// KegOperationLock is the root-level filesystem lock directory used to -// serialize complete operations across FsRepo instances and processes. -const KegOperationLock = ".keg-operation-lock" - -type kegBoundaryMode uint8 - -const ( - kegBoundaryRead kegBoundaryMode = iota + 1 - kegBoundaryWrite -) - -type kegBoundaryContextKey struct{} - -type kegBoundaryContext struct { - owner any - mode kegBoundaryMode -} - -func boundaryMode(ctx context.Context, owner any) kegBoundaryMode { - state, _ := ctx.Value(kegBoundaryContextKey{}).(kegBoundaryContext) - if state.owner == owner { - return state.mode - } - return 0 -} - -func contextWithBoundary(ctx context.Context, owner any, mode kegBoundaryMode) context.Context { - return context.WithValue(ctx, kegBoundaryContextKey{}, kegBoundaryContext{owner: owner, mode: mode}) -} - -// kegOperationBoundary is a cancellation-aware RW lock. It belongs to a -// repository, so every LocalKeg sharing that repository shares the same -// operation boundary as well. -type kegOperationBoundary struct { - mu sync.Mutex - readers int - writer bool - waiters chan struct{} -} - -func (b *kegOperationBoundary) changed() { - if b.waiters != nil { - close(b.waiters) - } - b.waiters = make(chan struct{}) -} - -func (b *kegOperationBoundary) acquire(ctx context.Context, write bool) (func(), error) { - for { - if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrLockTimeout, err) - } - b.mu.Lock() - available := !b.writer && (!write || b.readers == 0) - if available { - if write { - b.writer = true - } else { - b.readers++ - } - b.mu.Unlock() - return func() { - b.mu.Lock() - if write { - b.writer = false - } else { - b.readers-- - } - b.changed() - b.mu.Unlock() - }, nil - } - if b.waiters == nil { - b.waiters = make(chan struct{}) - } - waiters := b.waiters - b.mu.Unlock() - select { - case <-ctx.Done(): - return nil, fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-waiters: - } - } -} - -func (r *MemoryRepo) WithKegRead(ctx context.Context, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - if boundaryMode(ctx, r) != 0 { - return fn(ctx) - } - release, err := r.operationBoundary.acquire(ctx, false) - if err != nil { - return err - } - defer release() - return fn(contextWithBoundary(ctx, r, kegBoundaryRead)) -} - -func (r *MemoryRepo) WithKegWrite(ctx context.Context, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - switch boundaryMode(ctx, r) { - case kegBoundaryWrite: - return fn(ctx) - case kegBoundaryRead: - return ErrKegLockUpgrade - } - release, err := r.operationBoundary.acquire(ctx, true) - if err != nil { - return err - } - defer release() - err = fn(contextWithBoundary(ctx, r, kegBoundaryWrite)) - // Advance even on callback failure: low-level repository callers can leave - // partial state, and conservative cache invalidation is safer than assuming - // every failed operation restored itself perfectly. - r.operationGeneration.Add(1) - return err -} - -func (r *MemoryRepo) kegOperationGeneration() uint64 { - return r.operationGeneration.Load() -} - -func (f *FsRepo) WithKegRead(ctx context.Context, fn func(context.Context) error) error { - return f.withKegOperation(ctx, kegBoundaryRead, fn) -} - -func (f *FsRepo) WithKegWrite(ctx context.Context, fn func(context.Context) error) error { - return f.withKegOperation(ctx, kegBoundaryWrite, fn) -} - -// withKegOperation deliberately uses the same exclusive root lock for reads -// and writes. That is the conservative filesystem guarantee: a multi-file -// aggregate read cannot observe half of a concurrent write, including a write -// performed by another process. -func (f *FsRepo) withKegOperation(ctx context.Context, mode kegBoundaryMode, fn func(context.Context) error) error { - if fn == nil { - return fmt.Errorf("fn required") - } - switch held := boundaryMode(ctx, f); { - case held == kegBoundaryWrite: - return fn(ctx) - case held == kegBoundaryRead && mode == kegBoundaryRead: - return fn(ctx) - case held == kegBoundaryRead && mode == kegBoundaryWrite: - return ErrKegLockUpgrade - } - if err := ctx.Err(); err != nil { - return fmt.Errorf("%w: %w", ErrLockTimeout, err) - } - if err := f.runtime.Mkdir(f.Root, 0o755, true); err != nil { - return errors.Join(ErrLock, NewBackendError(f.Name(), "WithKegOperation", 0, err, false)) - } - lockPath := filepath.Join(f.Root, KegOperationLock) - for { - err := f.runtime.Mkdir(lockPath, 0o700, false) - if err == nil { - f.writeLockMetadata(lockPath) - break - } - if os.IsExist(err) { - if f.isLockStale(lockMetadataPath(lockPath)) { - _ = f.runtime.Remove(lockPath, true) - continue - } - select { - case <-ctx.Done(): - return fmt.Errorf("%w: %w", ErrLockTimeout, ctx.Err()) - case <-time.After(25 * time.Millisecond): - } - continue - } - return errors.Join(ErrLock, NewBackendError(f.Name(), "WithKegOperation", 0, err, false)) - } - - runErr := fn(contextWithBoundary(ctx, f, mode)) - unlockErr := f.runtime.Remove(lockPath, true) - if unlockErr != nil && !os.IsNotExist(unlockErr) { - unlockErr = errors.Join(ErrLock, NewBackendError(f.Name(), "WithKegOperationUnlock", 0, unlockErr, false)) - } else { - unlockErr = nil - } - return errors.Join(runErr, unlockErr) -} diff --git a/pkg/keg/repo_snapshots_test.go b/pkg/keg/repo_snapshots_test.go index 7aa924d9..4c344175 100644 --- a/pkg/keg/repo_snapshots_test.go +++ b/pkg/keg/repo_snapshots_test.go @@ -2,8 +2,6 @@ package keg_test import ( "context" - "os" - "path/filepath" "testing" "time" @@ -17,7 +15,6 @@ type snapshotRepo struct { keg.Repository keg.RepositorySnapshots } - root string } func TestRepositorySnapshots_Contract(t *testing.T) { @@ -28,7 +25,6 @@ func TestRepositorySnapshots_Contract(t *testing.T) { new func(*testing.T) (context.Context, snapshotRepo) }{ {name: "memory", new: newMemorySnapshotRepo}, - {name: "filesystem", new: newFilesystemSnapshotRepo}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -129,7 +125,6 @@ func TestRepositorySnapshots_Conflict(t *testing.T) { new func(*testing.T) (context.Context, snapshotRepo) }{ {name: "memory", new: newMemorySnapshotRepo}, - {name: "filesystem", new: newFilesystemSnapshotRepo}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -165,78 +160,12 @@ func TestRepositorySnapshots_Conflict(t *testing.T) { } } -func TestFsRepo_SnapshotCheckpointRollover(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t) - ctx := fx.Context() - root := t.TempDir() - - repo := keg.NewFsRepo(root, fx.Runtime()) - repo.SnapshotCheckpointInterval = 1 - - id := keg.NodeId{ID: 9} - stats1 := snapshotStats(time.Date(2026, 2, 26, 12, 0, 0, 0, time.UTC), "one", "one lead", "h1") - writeSnapshotState(t, ctx, repo, id, "# One\n", "title: One\n", stats1) - _, err := repo.AppendSnapshot(ctx, id, keg.SnapshotWrite{ - ExpectedParent: 0, - Message: "one", - Meta: []byte("title: One\n"), - Stats: stats1, - Content: keg.SnapshotContentWrite{Kind: keg.SnapshotContentKindFull, Data: []byte("# One\n")}, - }) - require.NoError(t, err) - - stats2 := snapshotStats(time.Date(2026, 2, 26, 13, 0, 0, 0, time.UTC), "two", "two lead", "h2") - writeSnapshotState(t, ctx, repo, id, "# Two\n", "title: Two\n", stats2) - _, err = repo.AppendSnapshot(ctx, id, keg.SnapshotWrite{ - ExpectedParent: 1, - Message: "two", - Meta: []byte("title: Two\n"), - Stats: stats2, - Content: keg.SnapshotContentWrite{Kind: keg.SnapshotContentKindPatch, Base: 1, Data: []byte("# Two\n")}, - }) - require.NoError(t, err) - - stats3 := snapshotStats(time.Date(2026, 2, 26, 14, 0, 0, 0, time.UTC), "three", "three lead", "h3") - writeSnapshotState(t, ctx, repo, id, "# Three\n", "title: Three\n", stats3) - _, err = repo.AppendSnapshot(ctx, id, keg.SnapshotWrite{ - ExpectedParent: 2, - Message: "three", - Meta: []byte("title: Three\n"), - Stats: stats3, - Content: keg.SnapshotContentWrite{Kind: keg.SnapshotContentKindPatch, Base: 2, Data: []byte("# Three\n")}, - }) - require.NoError(t, err) - - _, err = repo.Runtime().Stat(filepath.Join(root, id.Path(), "snapshots", "1.full"), false) - require.NoError(t, err) - _, err = repo.Runtime().Stat(filepath.Join(root, id.Path(), "snapshots", "2.patch"), false) - require.NoError(t, err) - _, err = repo.Runtime().Stat(filepath.Join(root, id.Path(), "snapshots", "3.full"), false) - require.NoError(t, err) - _, err = repo.Runtime().Stat(filepath.Join(root, id.Path(), "snapshots", "3.patch"), false) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) -} - func newMemorySnapshotRepo(t *testing.T) (context.Context, snapshotRepo) { t.Helper() fx := NewSandbox(t) return fx.Context(), snapshotRepo{ name: "memory", - repo: keg.NewMemoryRepo(fx.Runtime()), - } -} - -func newFilesystemSnapshotRepo(t *testing.T) (context.Context, snapshotRepo) { - t.Helper() - fx := NewSandbox(t) - root := t.TempDir() - return fx.Context(), snapshotRepo{ - name: "filesystem", - repo: keg.NewFsRepo(root, fx.Runtime()), - root: root, + repo: newTestMemoryRepo(fx.Runtime()), } } diff --git a/pkg/keg/repository.go b/pkg/keg/repository.go index fc774aef..0ea36e43 100644 --- a/pkg/keg/repository.go +++ b/pkg/keg/repository.go @@ -94,14 +94,25 @@ type Repository interface { // This method should be idempotent and context-aware. ClearIndexes(ctx context.Context) error - // Repository config. This is the keg file + // Repository settings. This is the keg file - // ReadConfig reads repository-level keg configuration. - // Missing config should return typed/sentinel not-exist errors. - ReadConfig(ctx context.Context) (*Config, error) - // WriteConfig persists repository-level keg configuration. + // ReadSettings reads repository-level keg settings. + // Missing settings should return typed/sentinel not-exist errors. + ReadSettings(ctx context.Context) (*Settings, error) + // WriteSettings persists repository-level keg settings. // Implementations should perform atomic writes when possible. - WriteConfig(ctx context.Context, config *Config) error + WriteSettings(ctx context.Context, settings *Settings) error +} + +// RepositorySettingsDocuments preserves the exact persisted representation +// of the keg settings document for optimistic concurrency and round-trip +// editing. LocalKeg uses it when available and falls back to Repository's +// structured settings methods for older external repositories. +type RepositorySettingsDocuments interface { + // ReadSettingsDocument returns the settings bytes exactly as persisted. + ReadSettingsDocument(ctx context.Context) ([]byte, error) + // WriteSettingsDocument atomically persists the exact supplied settings bytes. + WriteSettingsDocument(ctx context.Context, data []byte) error } // RepositoryAtomicWrite optionally provides rollback for a complete KEG @@ -195,7 +206,8 @@ type RepositorySchemas interface { // CreateSchema stores a schema only when typeName does not already exist. // Exactly one concurrent creator succeeds; later creators return ErrExist. CreateSchema(ctx context.Context, typeName string, data []byte) error - // WriteSchema stores or replaces the raw YAML for typeName. + // WriteSchema stores raw YAML for a type whose existence the business layer + // has already verified. Schema creation is a separate operation. WriteSchema(ctx context.Context, typeName string, data []byte) error // DeleteSchema removes the stored schema for typeName. DeleteSchema(ctx context.Context, typeName string) error diff --git a/pkg/keg/schema.go b/pkg/keg/schema.go index 5edc3d98..58050176 100644 --- a/pkg/keg/schema.go +++ b/pkg/keg/schema.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/google/jsonschema-go/jsonschema" + "github.com/jlrickert/tapper/pkg/schemas" "gopkg.in/yaml.v3" ) @@ -19,9 +20,10 @@ const ( SchemasDir = "schemas" SchemaFileSuffix = ".schema.yaml" - // KegSchemaDefinitionSchemaURL is the public JSON Schema used by editor - // modelines for keg schema definition YAML. - KegSchemaDefinitionSchemaURL = "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/keg-schema-definition.json" + // KegSchemaDefinitionSchemaURL is the published JSON Schema for keg schema + // definition YAML. Editor modelines prefer the local copy materialized by + // pkg/schemas and fall back to this. + KegSchemaDefinitionSchemaURL = schemas.KegSchemaDefinitionURL schemaActorHeader = "Tapper-Schema-Actor" schemaModeHeader = "Tapper-Schema-Mode" @@ -288,7 +290,7 @@ func (k *LocalKeg) explicitSchemaRequired(ctx context.Context, op schemaWriteOpe if id.ID == 0 || (op != schemaWriteCreate && op != schemaWriteUpdate) { return false } - cfg, err := k.Repo.ReadConfig(ctx) + cfg, err := k.Repo.ReadSettings(ctx) if err != nil || cfg == nil || cfg.SchemaPolicy == nil || !cfg.SchemaPolicy.Strict { return false } @@ -763,11 +765,11 @@ func (k *LocalKeg) readSchema(ctx context.Context, typeName string) ([]byte, err return store.ReadSchema(ctx, typeName) } -func (k *LocalKeg) WriteSchema(ctx context.Context, typeName string, data []byte) error { - return k.withKegWrite(ctx, func(ctx context.Context) error { return k.writeSchema(ctx, typeName, data) }) +func (k *LocalKeg) WriteSchema(ctx context.Context, typeName string, data []byte, opts SchemaWriteOptions) error { + return k.withKegWrite(ctx, func(ctx context.Context) error { return k.writeSchema(ctx, typeName, data, opts) }) } -func (k *LocalKeg) writeSchema(ctx context.Context, typeName string, data []byte) error { +func (k *LocalKeg) writeSchema(ctx context.Context, typeName string, data []byte, opts SchemaWriteOptions) error { store, ok := repoSchemas(k.Repo) if !ok { return ErrNotSupported @@ -775,18 +777,32 @@ func (k *LocalKeg) writeSchema(ctx context.Context, typeName string, data []byte if _, err := validateSchemaDefinitionForType(typeName, data); err != nil { return err } + current, err := store.ReadSchema(ctx, typeName) + if err != nil { + return err + } + if err := checkExpectedHash("schema "+typeName, opts.ExpectedHash, DocumentHash(current), current); err != nil { + return err + } return store.WriteSchema(ctx, typeName, data) } -func (k *LocalKeg) DeleteSchema(ctx context.Context, typeName string) error { - return k.withKegWrite(ctx, func(ctx context.Context) error { return k.deleteSchema(ctx, typeName) }) +func (k *LocalKeg) DeleteSchema(ctx context.Context, typeName string, opts SchemaWriteOptions) error { + return k.withKegWrite(ctx, func(ctx context.Context) error { return k.deleteSchema(ctx, typeName, opts) }) } -func (k *LocalKeg) deleteSchema(ctx context.Context, typeName string) error { +func (k *LocalKeg) deleteSchema(ctx context.Context, typeName string, opts SchemaWriteOptions) error { store, ok := repoSchemas(k.Repo) if !ok { return ErrNotSupported } + current, err := store.ReadSchema(ctx, typeName) + if err != nil { + return err + } + if err := checkExpectedHash("schema "+typeName, opts.ExpectedHash, DocumentHash(current), current); err != nil { + return err + } return store.DeleteSchema(ctx, typeName) } @@ -902,7 +918,7 @@ func (k *LocalKeg) enforceSchemaValidationResult(ctx context.Context, op schemaW func (k *LocalKeg) effectiveValidationMode(ctx context.Context, op schemaWriteOperation) ValidationMode { var policy *SchemaPolicy - if cfg, err := k.Repo.ReadConfig(ctx); err == nil && cfg != nil { + if cfg, err := k.Repo.ReadSettings(ctx); err == nil && cfg != nil { policy = cfg.SchemaPolicy } // Archives and restores preserve historical state rather than expressing a diff --git a/pkg/keg/schema_selection_test.go b/pkg/keg/schema_selection_test.go index 1c9a5e48..cf738445 100644 --- a/pkg/keg/schema_selection_test.go +++ b/pkg/keg/schema_selection_test.go @@ -34,7 +34,7 @@ func newSchemaSelectionKeg(t *testing.T) (*keg.LocalKeg, context.Context) { t.Helper() fx := NewSandbox(t) ctx := fx.Context() - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) + k := keg.NewLocalKeg(newTestMemoryRepo(fx.Runtime()), fx.Runtime()) require.NoError(t, k.Init(ctx)) require.NoError(t, k.CreateSchema(ctx, "task", []byte(selectionSchemaTask))) require.NoError(t, k.CreateSchema(ctx, "note", []byte(selectionSchemaNote))) @@ -55,7 +55,7 @@ func TestExplicitSchemaSelectionStrictModeMatrix(t *testing.T) { } t.Run(name, func(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy.Strict = strict cfg.SchemaPolicy.Human = mode cfg.SchemaPolicy.Agent = mode @@ -84,15 +84,19 @@ func TestExplicitSchemaSelectionPersistsReplacesAndRejectsConflicts(t *testing.T require.True(t, ok) require.Equal(t, "task", typeName) - _, err = k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: created.ID, Schema: "note", Content: []byte("# Reclassified\n")}) + view, err := k.ReadNode(ctx, created.ID) + require.NoError(t, err) + _, err = k.UpdateNode(ctx, keg.NodeUpdateOptions{ID: created.ID, Schema: "note", Content: []byte("# Reclassified\n"), ExpectedHash: view.Hash()}) require.NoError(t, err) meta, err = k.GetMeta(ctx, created.ID) require.NoError(t, err) typeName, _ = meta.Get("type") require.Equal(t, "note", typeName) + view, err = k.ReadNode(ctx, created.ID) + require.NoError(t, err) _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ - ID: created.ID, Schema: "note", Meta: []byte("type: task\n"), HasMeta: true, + ID: created.ID, Schema: "note", Meta: []byte("type: task\n"), HasMeta: true, ExpectedHash: view.Hash(), }}) require.ErrorIs(t, err, keg.ErrSchemaInvalid) require.Contains(t, err.Error(), "selected schema \"note\" conflicts with metadata type \"task\"") @@ -124,9 +128,13 @@ func TestSchemaSelectionBatchFailureIsAtomic(t *testing.T) { require.NoError(t, err) before, err := k.DexArtifacts(ctx) require.NoError(t, err) + one, err := k.ReadNode(ctx, created[0].ID) + require.NoError(t, err) + two, err := k.ReadNode(ctx, created[1].ID) + require.NoError(t, err) _, err = k.UpdateNodes(ctx, []keg.NodeUpdateOptions{ - {ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true}, - {ID: created[1].ID, Content: []byte("# Missing selection\n"), HasContent: true, SnapshotBefore: true}, + {ID: created[0].ID, Schema: "task", Content: []byte("# Changed\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: one.Hash()}, + {ID: created[1].ID, Content: []byte("# Missing selection\n"), HasContent: true, SnapshotBefore: true, ExpectedHash: two.Hash()}, }) require.ErrorIs(t, err, keg.ErrSchemaInvalid) var batchErr *keg.BatchMutationError @@ -143,9 +151,9 @@ func TestSchemaSelectionBatchFailureIsAtomic(t *testing.T) { require.Equal(t, before.Indexes, after.Indexes) } -func TestStrictSchemaSelectionExemptsMoveRemoveAndRedirect(t *testing.T) { +func TestStrictSchemaSelectionExemptsMoveAndRemove(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy = &keg.SchemaPolicy{Strict: true, Human: keg.ValidationModeBlock} })) created, err := k.CreateNodes(ctx, []keg.NodeCreate{ @@ -155,20 +163,13 @@ func TestStrictSchemaSelectionExemptsMoveRemoveAndRedirect(t *testing.T) { require.NoError(t, err) moved := keg.NodeId{ID: 20} - _, err = k.Move(ctx, created[1].ID, moved) + _, err = k.Move(ctx, moveOptions(t, ctx, k, created[1].ID, moved)) require.NoError(t, err) content, err := k.GetContent(ctx, created[0].ID) require.NoError(t, err) require.Contains(t, string(content), "../20") - redirected, err := k.ReplaceNodesWithRedirects(ctx, []keg.NodeRedirect{{ - ID: created[0].ID, Target: "keg:archive", TargetID: keg.NodeId{ID: 7}, - }}) - require.NoError(t, err) - require.Nil(t, redirected.Failure) - require.Equal(t, []keg.NodeId{created[0].ID}, redirected.Replaced) - - _, err = k.Remove(ctx, moved) + _, err = k.Remove(ctx, removeOptions(t, ctx, k, moved)) require.NoError(t, err) } @@ -176,7 +177,7 @@ func TestStrictSchemaSelectionRejectsLegacyMetadataMutation(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) created, err := k.Create(ctx, &keg.CreateOptions{Schema: "note", Body: []byte("# Typed\n")}) require.NoError(t, err) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy = &keg.SchemaPolicy{Strict: true, Human: keg.ValidationModeBlock} })) @@ -191,7 +192,7 @@ func TestStrictSchemaSelectionRejectsLegacyMetadataMutation(t *testing.T) { func TestSchemaAwareDirectWritesPersistReplaceAndMutateAtomically(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) humanCtx := keg.WithValidationActor(ctx, keg.ValidationActorHuman) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy = &keg.SchemaPolicy{Strict: true, Human: keg.ValidationModeBlock} })) created, err := k.Create(humanCtx, &keg.CreateOptions{Schema: "note", Body: []byte("# Typed\n")}) @@ -241,7 +242,7 @@ func TestSchemaAwareDirectWritesPersistReplaceAndMutateAtomically(t *testing.T) func TestValidateNodePayloadProjectsSchemaWithoutPersistence(t *testing.T) { k, ctx := newSchemaSelectionKeg(t) humanCtx := keg.WithValidationActor(ctx, keg.ValidationActorHuman) - require.NoError(t, k.UpdateConfig(ctx, func(cfg *keg.Config) { + require.NoError(t, k.UpdateSettings(ctx, func(cfg *keg.Settings) { cfg.SchemaPolicy = &keg.SchemaPolicy{Strict: true, Human: keg.ValidationModeBlock} })) created, err := k.Create(humanCtx, &keg.CreateOptions{Schema: "note", Body: []byte("# Stored\n")}) diff --git a/pkg/keg/schema_test.go b/pkg/keg/schema_test.go index 9550429f..d5c014f8 100644 --- a/pkg/keg/schema_test.go +++ b/pkg/keg/schema_test.go @@ -17,10 +17,7 @@ func TestCreateSchemaConcurrentExactlyOneWinner(t *testing.T) { name string repo func(*sandbox.Sandbox) kegpkg.Repository }{ - {name: "memory", repo: func(f *sandbox.Sandbox) kegpkg.Repository { return kegpkg.NewMemoryRepo(f.Runtime()) }}, - {name: "filesystem", repo: func(f *sandbox.Sandbox) kegpkg.Repository { - return kegpkg.NewFsRepo("~/schema-concurrent", f.Runtime()) - }}, + {name: "memory", repo: func(f *sandbox.Sandbox) kegpkg.Repository { return newTestMemoryRepo(f.Runtime()) }}, } { t.Run(tc.name, func(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) @@ -57,7 +54,7 @@ func TestCreateSchemaConcurrentExactlyOneWinner(t *testing.T) { func TestSchemaValidationCreatePolicy(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) schema := []byte(`type: task @@ -74,7 +71,7 @@ markdown: level: 2 required: true `) - if err := k.WriteSchema(ctx, "task", schema); err != nil { + if err := k.CreateSchema(ctx, "task", schema); err != nil { t.Fatalf("WriteSchema: %v", err) } @@ -116,9 +113,9 @@ markdown: func TestSchemaValidationActorOverrides(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) - if err := k.WriteSchema(ctx, "task", []byte(`type: task + if err := k.CreateSchema(ctx, "task", []byte(`type: task meta: type: object required: ["type"] @@ -130,14 +127,14 @@ markdown: `)); err != nil { t.Fatalf("WriteSchema: %v", err) } - if err := k.UpdateConfig(ctx, func(cfg *kegpkg.Config) { + if err := k.UpdateSettings(ctx, func(cfg *kegpkg.Settings) { cfg.SchemaPolicy = &kegpkg.SchemaPolicy{ Human: kegpkg.ValidationModeBlock, Agent: kegpkg.ValidationModeOff, API: kegpkg.ValidationModeWarn, } }); err != nil { - t.Fatalf("UpdateConfig: %v", err) + t.Fatalf("UpdateSettings: %v", err) } invalid := &kegpkg.CreateOptions{Schema: "missing", Body: []byte("# Unknown schema\n")} @@ -156,7 +153,7 @@ markdown: func TestSnapshotReplayPersistsOmegaFromRelationMaturity(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) evidenceSchema := []byte(`type: evidence @@ -172,7 +169,7 @@ meta: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "evidence", evidenceSchema); err != nil { + if err := k.CreateSchema(ctx, "evidence", evidenceSchema); err != nil { t.Fatalf("WriteSchema evidence: %v", err) } noteSchema := []byte(`type: note @@ -201,7 +198,7 @@ relations: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", noteSchema); err != nil { + if err := k.CreateSchema(ctx, "note", noteSchema); err != nil { t.Fatalf("WriteSchema note: %v", err) } @@ -293,7 +290,7 @@ markdown: func TestSnapshotReplayPersistsOmegaFromNestedMetadataMaturity(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) schema := []byte(`type: note @@ -319,7 +316,7 @@ meta: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", schema); err != nil { + if err := k.CreateSchema(ctx, "note", schema); err != nil { t.Fatalf("WriteSchema note: %v", err) } id, err := k.Create(ctx, &kegpkg.CreateOptions{ @@ -361,7 +358,7 @@ markdown: func TestSnapshotReplayPersistsOmegaFromLegacyTopLevelMetadataMaturity(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) schema := []byte(`type: note @@ -374,7 +371,7 @@ maturity: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", schema); err != nil { + if err := k.CreateSchema(ctx, "note", schema); err != nil { t.Fatalf("WriteSchema note: %v", err) } id, err := k.Create(ctx, &kegpkg.CreateOptions{ @@ -403,14 +400,14 @@ markdown: func TestSnapshotReplayCombinesMetadataAndRelationMaturity(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) evidenceSchema := []byte(`type: evidence markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "evidence", evidenceSchema); err != nil { + if err := k.CreateSchema(ctx, "evidence", evidenceSchema); err != nil { t.Fatalf("WriteSchema evidence: %v", err) } noteSchema := []byte(`type: note @@ -434,7 +431,7 @@ relations: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", noteSchema); err != nil { + if err := k.CreateSchema(ctx, "note", noteSchema); err != nil { t.Fatalf("WriteSchema note: %v", err) } @@ -475,7 +472,7 @@ markdown: func TestSchemaRelationMaturityValidation(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) valid := []byte(`type: note @@ -494,7 +491,7 @@ relations: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", valid); err != nil { + if err := k.CreateSchema(ctx, "note", valid); err != nil { t.Fatalf("WriteSchema valid: %v", err) } parsed, err := kegpkg.ParseSchemaDefinition(valid) @@ -518,7 +515,7 @@ relations: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", oldShape); !errors.Is(err, kegpkg.ErrInvalid) { + if err := k.CreateSchema(ctx, "note", oldShape); !errors.Is(err, kegpkg.ErrInvalid) { t.Fatalf("WriteSchema old relation shape error = %v, want ErrInvalid", err) } @@ -531,7 +528,7 @@ relations: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", missingAttribute); !errors.Is(err, kegpkg.ErrInvalid) { + if err := k.CreateSchema(ctx, "note", missingAttribute); !errors.Is(err, kegpkg.ErrInvalid) { t.Fatalf("WriteSchema missing attribute error = %v, want ErrInvalid", err) } } @@ -539,7 +536,7 @@ markdown: func TestSchemaTopLevelMaturityValidation(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) valid := []byte(`type: note @@ -552,7 +549,7 @@ maturity: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", valid); err != nil { + if err := k.CreateSchema(ctx, "note", valid); err != nil { t.Fatalf("WriteSchema valid: %v", err) } parsed, err := kegpkg.ParseSchemaDefinition(valid) @@ -623,7 +620,7 @@ markdown: } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if err := k.WriteSchema(ctx, "note", []byte(tc.body)); !errors.Is(err, kegpkg.ErrInvalid) { + if err := k.CreateSchema(ctx, "note", []byte(tc.body)); !errors.Is(err, kegpkg.ErrInvalid) { t.Fatalf("WriteSchema error = %v, want ErrInvalid", err) } }) @@ -633,7 +630,7 @@ markdown: func TestSchemaNestedMetadataMaturityValidation(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) valid := []byte(`type: note @@ -651,7 +648,7 @@ meta: markdown: requireTitle: true `) - if err := k.WriteSchema(ctx, "note", valid); err != nil { + if err := k.CreateSchema(ctx, "note", valid); err != nil { t.Fatalf("WriteSchema valid: %v", err) } parsed, err := kegpkg.ParseSchemaDefinition(valid) @@ -779,7 +776,7 @@ markdown: } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if err := k.WriteSchema(ctx, "note", []byte(tc.body)); !errors.Is(err, kegpkg.ErrInvalid) { + if err := k.CreateSchema(ctx, "note", []byte(tc.body)); !errors.Is(err, kegpkg.ErrInvalid) { t.Fatalf("WriteSchema error = %v, want ErrInvalid", err) } }) @@ -794,10 +791,10 @@ markdown: func TestZeroNodeExemptFromRequiredType(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) schema := []byte("type: task\nmeta:\n type: object\n required: [\"type\"]\n") - if err := k.WriteSchema(ctx, "task", schema); err != nil { + if err := k.CreateSchema(ctx, "task", schema); err != nil { t.Fatalf("WriteSchema: %v", err) } @@ -858,9 +855,9 @@ func metaWithType(t *testing.T, ctx context.Context, k *kegpkg.LocalKeg, id kegp func TestZeroNodeDoctorReportsRealProblemsOnly(t *testing.T) { f := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) ctx := context.Background() - k := kegpkg.NewLocalKeg(kegpkg.NewMemoryRepo(f.Runtime()), f.Runtime()) + k := kegpkg.NewLocalKeg(newTestMemoryRepo(f.Runtime()), f.Runtime()) initNonStrictTestKeg(t, k, ctx) - if err := k.WriteSchema(ctx, "task", []byte("type: task\nmeta:\n type: object\n")); err != nil { + if err := k.CreateSchema(ctx, "task", []byte("type: task\nmeta:\n type: object\n")); err != nil { t.Fatalf("WriteSchema: %v", err) } diff --git a/pkg/keg/snapshot_indexes.go b/pkg/keg/snapshot_indexes.go index 459eb907..42c6b588 100644 --- a/pkg/keg/snapshot_indexes.go +++ b/pkg/keg/snapshot_indexes.go @@ -150,6 +150,16 @@ func (k *LocalKeg) loadTimelineSnapshotStates(ctx context.Context) ([]timelineSn } for _, snap := range nodeSnapshots { loaded, content, meta, stats, err := snapshots.GetSnapshot(ctx, id, snap.ID, SnapshotReadOptions{ResolveContent: true}) + if err != nil && errors.Is(err, ErrConflict) { + if unchecked, ok := k.Repo.(interface { + readContentAtUnchecked(context.Context, NodeId, RevisionID) ([]byte, error) + }); ok { + loaded, _, meta, stats, err = snapshots.GetSnapshot(ctx, id, snap.ID, SnapshotReadOptions{}) + if err == nil { + content, err = unchecked.readContentAtUnchecked(ctx, id, snap.ID) + } + } + } if err != nil { return nil, fmt.Errorf("read snapshot node %s rev %d: %w", id.Path(), snap.ID, err) } diff --git a/pkg/keg/snapshot_indexes_test.go b/pkg/keg/snapshot_indexes_test.go index f0e98ceb..fb19e71d 100644 --- a/pkg/keg/snapshot_indexes_test.go +++ b/pkg/keg/snapshot_indexes_test.go @@ -116,7 +116,7 @@ func TestTimelineIndex_EmitsOmegaUpdatesAndIndexPersistsFinalOmega(t *testing.T) k, rt := newSnapshotIndexTestKeg(t) ctx := t.Context() - require.NoError(t, k.WriteSchema(ctx, "evidence", []byte(`type: evidence + require.NoError(t, k.CreateSchema(ctx, "evidence", []byte(`type: evidence meta: type: object properties: @@ -125,7 +125,7 @@ meta: markdown: requireTitle: true `))) - require.NoError(t, k.WriteSchema(ctx, "note", []byte(`type: note + require.NoError(t, k.CreateSchema(ctx, "note", []byte(`type: note relations: - name: support type: evidence @@ -213,10 +213,10 @@ func newSnapshotIndexTestKeg(t *testing.T) (*LocalKeg, *toolkit.Runtime) { rt, err := toolkit.NewTestRuntime(t.TempDir(), "/home/testuser", "testuser") require.NoError(t, err) - repo := NewMemoryRepo(rt) + repo := newTestMemoryRepo(rt) k := NewLocalKeg(repo, rt) require.NoError(t, k.Init(t.Context())) - require.NoError(t, k.UpdateConfig(t.Context(), func(cfg *Config) { + require.NoError(t, k.UpdateSettings(t.Context(), func(cfg *Settings) { cfg.SchemaPolicy.Strict = false })) return k, rt diff --git a/pkg/keg/snapshot_policy.go b/pkg/keg/snapshot_policy.go index 577b4add..441b762e 100644 --- a/pkg/keg/snapshot_policy.go +++ b/pkg/keg/snapshot_policy.go @@ -32,23 +32,41 @@ func AutoSnapshotMessage(idleAfter time.Duration) string { // automatic snapshots for nodes whose live content has drifted from the latest // snapshot after the configured idle window. func (k *LocalKeg) RunSnapshotPolicy(ctx context.Context) (SnapshotPolicyResult, error) { + preflight, err := withKegReadValue(ctx, k, k.snapshotPolicySettings) + if err != nil { + return SnapshotPolicyResult{}, err + } + if preflight.Mode == SnapshotModeOff { + return preflight, nil + } return withKegWriteValue(ctx, k, k.runSnapshotPolicy) } -func (k *LocalKeg) runSnapshotPolicy(ctx context.Context) (SnapshotPolicyResult, error) { +func (k *LocalKeg) snapshotPolicySettings(ctx context.Context) (SnapshotPolicyResult, error) { if err := k.checkKegExists(ctx); err != nil { return SnapshotPolicyResult{}, fmt.Errorf("failed to run snapshot policy: %w", err) } - - cfg, err := k.Repo.ReadConfig(ctx) + cfg, err := k.Repo.ReadSettings(ctx) if err != nil { - return SnapshotPolicyResult{}, fmt.Errorf("read snapshot policy config: %w", err) + return SnapshotPolicyResult{}, fmt.Errorf("read snapshot policy settings: %w", err) } mode, idleAfter, err := cfg.SnapshotPolicy() if err != nil { return SnapshotPolicyResult{}, err } - result := SnapshotPolicyResult{Mode: mode, IdleAfter: idleAfter} + return SnapshotPolicyResult{Mode: mode, IdleAfter: idleAfter}, nil +} + +func (k *LocalKeg) runSnapshotPolicy(ctx context.Context) (SnapshotPolicyResult, error) { + if err := k.checkKegExists(ctx); err != nil { + return SnapshotPolicyResult{}, fmt.Errorf("failed to run snapshot policy: %w", err) + } + + result, err := k.snapshotPolicySettings(ctx) + if err != nil { + return SnapshotPolicyResult{}, err + } + mode, idleAfter := result.Mode, result.IdleAfter if mode == SnapshotModeOff { return result, nil } @@ -148,6 +166,13 @@ func (k *LocalKeg) policySnapshotEligibleLocked(ctx context.Context, id NodeId, } latestContent, err := snapshots.ReadContentAt(ctx, id, latest.ID) + if err != nil && errors.Is(err, ErrConflict) { + if unchecked, ok := k.Repo.(interface { + readContentAtUnchecked(context.Context, NodeId, RevisionID) ([]byte, error) + }); ok { + latestContent, err = unchecked.readContentAtUnchecked(ctx, id, latest.ID) + } + } if err != nil { return false, fmt.Errorf("read latest snapshot content rev %d: %w", latest.ID, err) } diff --git a/pkg/keg/snapshot_policy_internal_test.go b/pkg/keg/snapshot_policy_internal_test.go index 09beb859..6170c226 100644 --- a/pkg/keg/snapshot_policy_internal_test.go +++ b/pkg/keg/snapshot_policy_internal_test.go @@ -2,6 +2,7 @@ package keg import ( "context" + "sync/atomic" "testing" "time" @@ -9,6 +10,70 @@ import ( "github.com/stretchr/testify/require" ) +type snapshotBoundaryRepo struct { + *testMemoryRepository + reads atomic.Int32 + writes atomic.Int32 + settingsUnderWrite atomic.Int32 + inWrite atomic.Bool +} + +func (r *snapshotBoundaryRepo) WithKegRead(ctx context.Context, fn func(context.Context) error) error { + r.reads.Add(1) + return r.testMemoryRepository.WithKegRead(ctx, fn) +} + +func (r *snapshotBoundaryRepo) WithKegWrite(ctx context.Context, fn func(context.Context) error) error { + r.writes.Add(1) + return r.testMemoryRepository.WithKegWrite(ctx, func(writeCtx context.Context) error { + r.inWrite.Store(true) + defer r.inWrite.Store(false) + return fn(writeCtx) + }) +} + +func (r *snapshotBoundaryRepo) ReadSettings(ctx context.Context) (*Settings, error) { + if r.inWrite.Load() { + r.settingsUnderWrite.Add(1) + } + return r.testMemoryRepository.ReadSettings(ctx) +} + +func (r *snapshotBoundaryRepo) reset() { + r.reads.Store(0) + r.writes.Store(0) + r.settingsUnderWrite.Store(0) +} + +func TestSnapshotPolicy_PreflightsOffWithoutWriteAndRechecksEnabledUnderWrite(t *testing.T) { + fx := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) + base := newTestMemoryRepo(fx.Runtime()) + repo := &snapshotBoundaryRepo{testMemoryRepository: base} + k := NewLocalKeg(repo, fx.Runtime()) + require.NoError(t, k.Init(t.Context())) + require.NoError(t, k.UpdateSettings(t.Context(), func(cfg *Settings) { + cfg.SchemaPolicy.Strict = false + cfg.Snapshots = &SnapshotSettings{Mode: SnapshotModeOff} + })) + + repo.reset() + result, err := k.RunSnapshotPolicy(t.Context()) + require.NoError(t, err) + require.Equal(t, SnapshotModeOff, result.Mode) + require.Equal(t, int32(1), repo.reads.Load()) + require.Zero(t, repo.writes.Load(), "disabled policies must never enter the write boundary") + + require.NoError(t, k.UpdateSettings(t.Context(), func(cfg *Settings) { + cfg.Snapshots = &SnapshotSettings{Mode: SnapshotModeAuto, IdleAfter: "1h"} + })) + repo.reset() + _, err = k.RunSnapshotPolicy(t.Context()) + require.NoError(t, err) + require.Equal(t, int32(1), repo.reads.Load()) + require.Equal(t, int32(1), repo.writes.Load()) + require.Equal(t, int32(1), repo.settingsUnderWrite.Load(), "enabled policy must re-read settings after acquiring the write boundary") +} + func TestSnapshotPolicy_BadLatestContentHashWithIdenticalContentDoesNotDuplicate(t *testing.T) { for _, tc := range []struct { name string @@ -28,8 +93,8 @@ func TestSnapshotPolicy_BadLatestContentHashWithIdenticalContentDoesNotDuplicate require.NoError(t, err) require.Len(t, result.Created, 1) - corruptLatestMemorySnapshot(t, repo, id.ID, func(entry *memorySnapshotEntry) { - entry.snapshot.ContentHash = tc.hash + corruptLatestMemorySnapshot(t, repo, id.ID, func(snapshot *Snapshot, _ *[]byte) { + snapshot.ContentHash = tc.hash }) result, err = k.RunSnapshotPolicy(ctx) @@ -62,9 +127,9 @@ func TestSnapshotPolicy_BadLatestContentHashWithDifferentContentCreatesSnapshot( require.NoError(t, err) require.Len(t, result.Created, 1) - corruptLatestMemorySnapshot(t, repo, id.ID, func(entry *memorySnapshotEntry) { - entry.snapshot.ContentHash = tc.hash - entry.content = []byte("# Legacy Drift Target\n\nolder content\n") + corruptLatestMemorySnapshot(t, repo, id.ID, func(snapshot *Snapshot, content *[]byte) { + snapshot.ContentHash = tc.hash + *content = []byte("# Legacy Drift Target\n\nolder content\n") }) result, err = k.RunSnapshotPolicy(ctx) @@ -79,14 +144,14 @@ func TestSnapshotPolicy_BadLatestContentHashWithDifferentContentCreatesSnapshot( } } -func newInternalSnapshotPolicyTestKeg(t *testing.T) (*sandbox.Sandbox, *LocalKeg, *MemoryRepo) { +func newInternalSnapshotPolicyTestKeg(t *testing.T) (*sandbox.Sandbox, *LocalKeg, *testMemoryRepository) { t.Helper() fx := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - repo := NewMemoryRepo(fx.Runtime()) + repo := newTestMemoryRepo(fx.Runtime()) k := NewLocalKeg(repo, fx.Runtime()) require.NoError(t, k.Init(context.Background())) - require.NoError(t, k.UpdateConfig(context.Background(), func(cfg *Config) { + require.NoError(t, k.UpdateSettings(context.Background(), func(cfg *Settings) { cfg.SchemaPolicy.Strict = false })) _, err := k.AppendSnapshot(context.Background(), NodeId{ID: 0}, "seed zero") @@ -94,13 +159,7 @@ func newInternalSnapshotPolicyTestKeg(t *testing.T) (*sandbox.Sandbox, *LocalKeg return fx, k, repo } -func corruptLatestMemorySnapshot(t *testing.T, repo *MemoryRepo, id NodeId, mutate func(*memorySnapshotEntry)) { +func corruptLatestMemorySnapshot(t *testing.T, repo *testMemoryRepository, id NodeId, mutate func(*Snapshot, *[]byte)) { t.Helper() - - repo.mu.Lock() - defer repo.mu.Unlock() - entries := repo.snapshots[id] - require.NotEmpty(t, entries) - mutate(&entries[len(entries)-1]) - repo.snapshots[id] = entries + require.NoError(t, repo.corruptLatestSnapshot(id, mutate)) } diff --git a/pkg/keg/snapshot_policy_test.go b/pkg/keg/snapshot_policy_test.go index f353fb2b..46bcf583 100644 --- a/pkg/keg/snapshot_policy_test.go +++ b/pkg/keg/snapshot_policy_test.go @@ -47,8 +47,8 @@ func TestSnapshotPolicy_OffModeSkipsSnapshots(t *testing.T) { fx, k := newSnapshotPolicyTestKeg(t) ctx := fx.Context() - require.NoError(t, k.UpdateConfig(ctx, func(cfg *kegpkg.Config) { - cfg.Snapshots = &kegpkg.SnapshotConfig{Mode: kegpkg.SnapshotModeOff} + require.NoError(t, k.UpdateSettings(ctx, func(cfg *kegpkg.Settings) { + cfg.Snapshots = &kegpkg.SnapshotSettings{Mode: kegpkg.SnapshotModeOff} })) _, err := k.Create(ctx, &kegpkg.CreateOptions{Title: "No Auto Snapshot"}) require.NoError(t, err) @@ -136,7 +136,7 @@ func newSnapshotPolicyTestKeg(t *testing.T) (*sandbox.Sandbox, *kegpkg.LocalKeg) t.Helper() fx := NewSandbox(t) - repo := kegpkg.NewMemoryRepo(fx.Runtime()) + repo := newTestMemoryRepo(fx.Runtime()) k := kegpkg.NewLocalKeg(repo, fx.Runtime()) initNonStrictTestKeg(t, k, context.Background()) _, err := k.AppendSnapshot(context.Background(), kegpkg.NodeId{ID: 0}, "seed zero") diff --git a/pkg/keg/target.go b/pkg/keg/target.go index 22c64ca5..fe2b185a 100644 --- a/pkg/keg/target.go +++ b/pkg/keg/target.go @@ -1,10 +1,8 @@ package keg import ( - "errors" "fmt" "net/url" - "path/filepath" "regexp" "strings" @@ -12,24 +10,14 @@ import ( "gopkg.in/yaml.v3" ) -var scalarApiRE = regexp.MustCompile(`^([A-Za-z0-9_.-]+):\s*(.+)$`) +var scalarAPIRE = regexp.MustCompile(`^([A-Za-z0-9_.-]+):\s*(.+)$`) var dupSlashRE = regexp.MustCompile(`/+`) // Target describes a resolved KEG repository target. // -// Schema is the URI scheme when the target was written as a URL (for example -// "file", "http", "https"). Path is the URL path component or an absolute -// filesystem path when the target was supplied as a file path. -// // The Target type is the canonical, minimal shape used by tooling. Valid // input forms that map into Target include: // -// - File targets: -// - Scalar file paths such as "/abs/path", "./rel/path", "../rel/path", -// "~/path", or Windows drive paths. -// - Mapping form with a "file" key. File values are cleaned with -// filepath.Clean; Expand will attempt to expand a leading tilde. -// // - API or HTTP targets: // - Full URL scalars (http:// or https://). // - Mapping form with "url" and optional user/password/token/tokenEnv. @@ -43,7 +31,6 @@ var dupSlashRE = regexp.MustCompile(`/+`) // // Fields: // -// - File: filesystem path for a local keg target. // - Hub: hub name when using an API style target. // - Url: canonical URL when provided or parsed from a scalar. // - Namespace/KegName: structured hub pieces used to compose API paths. @@ -56,27 +43,21 @@ var dupSlashRE = regexp.MustCompile(`/+`) // production usage. // - Readonly: when true the target was requested read only. type Target struct { - // File is the file to use when the Target is a file - File string `yaml:"file,omitempty"` - // Hub is an optional explicit hub pin for a keg reference. It is normally - // empty: the hub is resolved from the Namespace via the tapper config's + // empty: the hub is resolved from the Namespace via the tapper settings's // namespaces map. The canonical keg reference does not carry a hub. Hub string `yaml:"hub,omitempty"` // HubURL is the resolved base URL for the hub (for example // "https://atlas.foldwise.ai"). It is derived at resolution time from the - // tapper config's hubs map and is intentionally not serialized. A keg + // tapper settings's hubs map and is intentionally not serialized. A keg // reference that reaches NewKegFromTarget without it was never resolved // against a hub and is rejected. HubURL string `yaml:"-"` - // Url is the url for the target when represented as a scalar or explicit - // mapping value. Url is used when the target was http/s, git, ssh, etc + // Url is the URL for a direct HTTP(S) target. Url string `yaml:"url,omitempty"` - Memory bool - // Namespace is the namespace owner for hub targets. The "@" sigil is // implied; do not store it. A user's default namespace shares their // username; organizations and other namespace types use the same field. @@ -93,8 +74,7 @@ type Target struct { Token string `yaml:"token,omitempty"` TokenEnv string `yaml:"tokenEnv,omitempty"` - // Readonly specifies in the target is readonly. Only api and file are - // writable + // Readonly specifies that the target is read only. Readonly bool `yaml:"readonly,omitempty"` } @@ -102,14 +82,10 @@ type TargetOption = func(t *Target) type HTTPOption = func(t *Target) const ( - SchemeMemory = "memory" - SchemeFile = "file" - SchemeGit = "git" - SchemeSSH = "ssh" - SchemeHTTP = "http" - SchemeHTTPs = "https" - SchemeAlias = "keg" - SchemeS3 = "s3" + SchemeHTTP = "http" + SchemeHTTPs = "https" + SchemeAlias = "keg" + schemeUnsupported = "unsupported" ) // NewApi constructs a Target representing a keg API endpoint. namespace is @@ -126,30 +102,6 @@ func NewApi(hub string, namespace, kegName string, opts ...TargetOption) Target return t } -// NewFile constructs a file target for a local filesystem path. The path is -// cleaned using filepath.Clean. -func NewFile(path string, opts ...TargetOption) Target { - p := filepath.Clean(path) - t := Target{ - File: p, - } - for _, o := range opts { - o(&t) - } - return t -} - -func NewMemory(kegalias string, opts ...TargetOption) Target { - t := Target{ - Memory: true, - KegName: kegalias, - } - for _, o := range opts { - o(&t) - } - return t -} - func WithReadonly() TargetOption { return func(t *Target) { t.Readonly = true @@ -181,18 +133,17 @@ func WithToken(token string) HTTPOption { // Parse parses a user-supplied target scalar into a Target. // // Accepted input forms: -// - File paths (absolute, ./, ../, ~, Windows drive). These produce File -// targets. // - Canonical keg reference "keg:@namespace/keg" (namespace optional as // "keg:keg"); "keg:/@namespace/keg" is an accepted variant. The leading // "@" sigil marks the namespace and is stripped on parse so the stored // namespace never carries it; Path() and String() re-apply it. The hub is // resolved from the namespace, never encoded in the reference. // - HTTP/HTTPS URL scalars. -// - Any URL-like scalar parsed by url.Parse. // -// The function is permissive with common variants (extra whitespace, duplicate -// slashes). It returns an error for empty or malformed keg references. +// Filesystem paths, file:// URLs, and every other scheme are unsupported. +// The function is permissive with common HTTP and keg-reference variants +// (extra whitespace and duplicate slashes). It returns an error for empty or +// malformed references. func Parse(raw string) (*Target, error) { value := strings.TrimSpace(raw) if value == "" { @@ -201,15 +152,10 @@ func Parse(raw string) (*Target, error) { detectedScheme := detectScheme(value) switch detectedScheme { - case SchemeFile: - t := Target{ - File: filepath.Clean(strings.TrimPrefix(value, "file://")), - } - return &t, nil case SchemeAlias: // Canonical keg reference: "keg:@namespace/kegName" (namespace optional → // "keg:kegName"). The hub is NOT encoded — it is resolved from the - // namespace via config. "keg:/@ns/keg" parses equivalently. The "@" sigil + // namespace via settings. "keg:/@ns/keg" parses equivalently. The "@" sigil // is stripped here; Path() and String() re-apply it. To pin a hub, use // the structured mapping form ({hub, namespace, name}). body := strings.TrimSpace(strings.TrimPrefix(value, SchemeAlias+":")) @@ -237,6 +183,8 @@ func Parse(raw string) (*Target, error) { if !strings.HasPrefix(value, "https://") { value = "https://" + value } + default: + return nil, unsupportedTarget(value) } // Otherwise, treat as URL-like and parse with url.Parse. @@ -245,6 +193,10 @@ func Parse(raw string) (*Target, error) { return nil, fmt.Errorf("unable to parse %s: %w", value, err) } + if u.Host == "" { + return nil, unsupportedTarget(value) + } + // Normalize path component by collapsing duplicate slashes. u.Path = dupSlashRE.ReplaceAllString(u.Path, "/") @@ -282,40 +234,22 @@ func Parse(raw string) (*Target, error) { return &kt, nil } -// Expand replaces environment variables and expands a leading tilde in File -// and Hub-related fields. It uses std.ExpandEnv and std.ExpandPath so behavior -// matches the rest of the code base. -// -// Errors from ExpandPath are collected and returned as a joined error so callers -// can see expansion issues. +// Expand replaces environment variables in target fields. func (k *Target) Expand(env toolkit.Env) error { - var errs []error - - expand := func(value string) string { - va := toolkit.ExpandEnv(env, value) - vb, err := toolkit.ExpandPath(env, va) - if err != nil { - errs = append(errs, err) - return va - } - return vb - } - k.File = expand(k.File) k.Url = toolkit.ExpandEnv(env, k.Url) k.Hub = toolkit.ExpandEnv(env, k.Hub) k.HubURL = toolkit.ExpandEnv(env, k.HubURL) k.Password = toolkit.ExpandEnv(env, k.Password) k.Token = toolkit.ExpandEnv(env, k.Token) k.TokenEnv = toolkit.ExpandEnv(env, k.TokenEnv) - return errors.Join(errs...) + return nil } -// UnmarshalYAML accepts either a scalar string (the URL or shorthand or file) -// or a mapping node that decodes into the full Target struct. Mapping form may -// include structured hub/user/keg or an explicit file field. +// UnmarshalYAML accepts either a remote URL or keg-reference scalar, or a +// mapping node that decodes into the full Target struct. Mapping form may +// include structured hub/namespace/keg fields. // -// When a scalar is provided the value is parsed via Parse which recognizes -// file scalars, shorthand hub forms, and URL scalars. +// Filesystem fields and file:// scalars are rejected. func (k *Target) UnmarshalYAML(node *yaml.Node) error { if node == nil { return nil @@ -333,6 +267,11 @@ func (k *Target) UnmarshalYAML(node *yaml.Node) error { *k = *kt return nil case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == "file" { + return unsupportedTarget(node.Content[i+1].Value) + } + } type tmp Target var t tmp if err := node.Decode(&t); err != nil { @@ -349,8 +288,13 @@ func (k *Target) UnmarshalYAML(node *yaml.Node) error { if !strings.HasPrefix(k.Url, "https://") { k.Url = "https://" + k.Url } + default: + return unsupportedTarget(k.Url) } } + if k.Scheme() == schemeUnsupported { + return unsupportedTarget(k.String()) + } return nil default: return fmt.Errorf("unsupported yaml node kind %d for KegUrl", node.Kind) @@ -360,13 +304,10 @@ func (k *Target) UnmarshalYAML(node *yaml.Node) error { // String returns a human-friendly representation of the target. A keg // reference renders in the canonical "keg:@namespace/kegName" form (namespace // omitted as "keg:kegName" when unset). The hub is NOT part of the reference — -// it is resolved from the namespace via config — so the scheme is always the -// real "keg" scheme, never a hub name. File targets return the file path; HTTP -// targets return the canonical Url. +// it is resolved from the namespace via settings — so the scheme is always the +// real "keg" scheme, never a hub name. HTTP targets return the canonical URL. func (kt *Target) String() string { switch kt.Scheme() { - case SchemeFile: - return kt.File case SchemeAlias: if kt.Namespace != "" { return SchemeAlias + ":@" + kt.Namespace + "/" + kt.KegName @@ -381,81 +322,61 @@ func (kt *Target) String() string { } // Scheme reports the inferred scheme for this Target value. A keg reference -// (identified by a Namespace owner, or an explicit Hub pin) implies the keg -// scheme. File implies a local file scheme. Otherwise we fall back to -// detectScheme on the Url. +// (identified by a Namespace owner, KegName, or explicit Hub pin) implies the +// keg scheme. Otherwise we classify the URL. func (kt *Target) Scheme() string { - if kt.File != "" { - return SchemeFile - } - if kt.Hub != "" || kt.Namespace != "" { + if kt.Hub != "" || kt.Namespace != "" || kt.KegName != "" { return SchemeAlias } return detectScheme(kt.Url) } -// Host returns the hostname portion for HTTP/HTTPS targets. For file targets -// it returns an empty string. +// Host returns the hostname portion for HTTP/HTTPS targets. func (kt *Target) Host() string { - switch kt.Scheme() { - case SchemeFile: - return "" - case SchemeHTTP, SchemeHTTPs: - u, _ := url.Parse(kt.Url) - return u.Hostname() - default: - u, _ := url.Parse(kt.Url) - return u.Hostname() - } + u, _ := url.Parse(kt.Url) + return u.Hostname() } func (kt *Target) Port() string { - switch kt.Scheme() { - case SchemeFile: - return "" - default: - u, _ := url.Parse(kt.Url) - return u.Port() - } + u, _ := url.Parse(kt.Url) + return u.Port() } func (kt *Target) Path() string { switch kt.Scheme() { - case SchemeFile: - return filepath.Clean(kt.File) case SchemeAlias: // Re-apply the @ sigil on the namespace; the stored value never carries it. - return filepath.Join("@"+kt.Namespace, kt.KegName) + if kt.Namespace == "" { + return kt.KegName + } + return "@" + kt.Namespace + "/" + kt.KegName default: u, _ := url.Parse(kt.Url) return u.Path } } -// detectScheme classifies raw into a scheme. It recognizes the explicit -// http/https/file URL schemes, the "keg:" keg-reference scheme, and otherwise -// treats typical filesystem path forms as SchemeFile. +// detectScheme classifies raw into a supported remote scheme. func detectScheme(raw string) string { if raw == "" { - return SchemeFile + return schemeUnsupported } // The keg scheme is the only ":" scalar we own. A prefix that - // is not "keg" is not a keg reference (it falls through to URL/file - // classification) — there is no ":@ns/keg" shorthand. - if m := scalarApiRE.FindStringSubmatch(raw); m != nil && m[1] == SchemeAlias { + // is not "keg" is not a keg reference — there is no + // ":@ns/keg" shorthand. + if m := scalarAPIRE.FindStringSubmatch(raw); m != nil && m[1] == SchemeAlias { return SchemeAlias } - // Try to parse as a URL first. This catches explicit schemes like - // "https://" or "file://". + // Try to parse as a URL first. if u, err := url.Parse(raw); err == nil && u.Scheme != "" { switch u.Scheme { case "http": return SchemeHTTP case "https": return SchemeHTTPs - case "file": - return SchemeFile + default: + return schemeUnsupported } } @@ -466,7 +387,7 @@ func detectScheme(raw string) string { strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") || strings.HasPrefix(raw, "~") { - return SchemeFile + return schemeUnsupported } // Check for implicit http website. @@ -475,14 +396,17 @@ func detectScheme(raw string) string { return SchemeHTTPs } - // Windows drive letter like "C:" should be treated as file. + // Windows drive letter like "C:" is an unsupported filesystem path. if len(raw) >= 2 && raw[1] == ':' && ((raw[0] >= 'A' && raw[0] <= 'Z') || (raw[0] >= 'a' && raw[0] <= 'z')) { - return SchemeFile + return schemeUnsupported } - // Fallback: treat as a local file path. - return SchemeFile + return schemeUnsupported +} + +func unsupportedTarget(raw string) error { + return fmt.Errorf("unsupported target %q: only keg references and HTTP(S) endpoints are supported: %w", raw, ErrNotSupported) } func getHostLikePath(raw string) string { diff --git a/pkg/keg/target_test.go b/pkg/keg/target_test.go index 0df05cd4..f3b3670b 100644 --- a/pkg/keg/target_test.go +++ b/pkg/keg/target_test.go @@ -1,8 +1,7 @@ package keg_test import ( - "net/url" - "os" + "errors" "path/filepath" "testing" @@ -12,283 +11,127 @@ import ( "gopkg.in/yaml.v3" ) -// Tests for parsing and YAML unmarshalling of kegpkg.Target values. -// The table driven tests cover file paths, file URIs, tilde expansion, -// relative paths, shorthand hub:@user/keg form, and HTTP/HTTPS URLs. -func TestParse_File_TableDriven(t *testing.T) { - // Use OS-specific temp dir so tests work across platforms. - tmpDir := os.TempDir() - absTmpKeg := filepath.Join(tmpDir, "keg") - // Use a file URI that uses forward slashes as URLs expect. - fileURI := "file://" + filepath.ToSlash(absTmpKeg) - - cases := []struct { - name string - raw string - expand bool // run kt.Expand(env) before assertions - wantErr bool - wantSchema string - wantFile string +func TestParseRemoteTargets(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + raw string + scheme string + canonical string + host string + path string }{ - { - name: "absolute path", - raw: absTmpKeg, - wantSchema: kegpkg.SchemeFile, - wantFile: absTmpKeg, - }, - { - name: "file uri", - raw: fileURI, - wantSchema: kegpkg.SchemeFile, - wantFile: absTmpKeg, - }, - { - name: "tilde path expands to home", - raw: "~/kegs/work", - expand: true, - wantSchema: kegpkg.SchemeFile, - wantFile: "~/kegs/work", - }, - { - name: "relative path", - raw: "kegs/work", - wantSchema: kegpkg.SchemeFile, - wantFile: "kegs/work", - }, - } - - for _, tc := range cases { + {name: "https", raw: "https://hub.example.com/api/v1/@team/kegs/docs", scheme: kegpkg.SchemeHTTPs, canonical: "https://hub.example.com/api/v1/@team/kegs/docs", host: "hub.example.com", path: "/api/v1/@team/kegs/docs"}, + {name: "http", raw: "http://localhost:8080/api/v1/@team/kegs/docs", scheme: kegpkg.SchemeHTTP, canonical: "http://localhost:8080/api/v1/@team/kegs/docs", host: "localhost", path: "/api/v1/@team/kegs/docs"}, + {name: "implicit https", raw: "hub.example.com/api/v1/@team/kegs/docs", scheme: kegpkg.SchemeHTTPs, canonical: "https://hub.example.com/api/v1/@team/kegs/docs", host: "hub.example.com", path: "/api/v1/@team/kegs/docs"}, + {name: "keg reference", raw: "keg:@team/docs", scheme: kegpkg.SchemeAlias, canonical: "keg:@team/docs", path: "@team/docs"}, + {name: "unqualified keg", raw: "keg:docs", scheme: kegpkg.SchemeAlias, canonical: "keg:docs", path: "docs"}, + } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - kt, err := kegpkg.Parse(tc.raw) + target, err := kegpkg.Parse(tc.raw) require.NoError(t, err) - if tc.expand { - err = kt.Expand(&toolkit.OsEnv{}) - require.NoError(t, err) - f, _ := toolkit.ExpandPath(&toolkit.OsEnv{}, tc.wantFile) - tc.wantFile = f - } - if tc.wantSchema != "" { - require.Equal(t, tc.wantSchema, kt.Scheme()) - } - if tc.wantFile != "" { - require.Equal(t, tc.wantFile, kt.File) - require.Equal(t, tc.wantFile, kt.Path()) - } + require.Equal(t, tc.scheme, target.Scheme()) + require.Equal(t, tc.canonical, target.String()) + require.Equal(t, tc.host, target.Host()) + require.Equal(t, filepath.FromSlash(tc.path), target.Path()) }) } } -// Table driven tests for YAML unmarshalling behavior. -// These ensure both scalar and mapping forms decode to the expected Target. -func TestUnmarshalYAML_TableDriven(t *testing.T) { - cases := []struct { - name string - rawYAML []byte - wantErr bool - wantSchema string - wantHost string - wantPath string - wantToken string - wantHub string - wantNamespace string - wantKegName string - wantFile string - wantUrl string - }{ - { - name: "https: simple url mapping", - rawYAML: []byte("url: example.com/owner/repo"), - wantSchema: kegpkg.SchemeHTTPs, - wantHost: "example.com", - wantPath: "/owner/repo", - wantUrl: "https://example.com/owner/repo", - }, - { - name: "https: simple url scalar", - rawYAML: []byte("example.com/owner/repo"), - wantSchema: kegpkg.SchemeHTTPs, - wantHost: "example.com", - wantPath: "/owner/repo", - wantUrl: "https://example.com/owner/repo", - }, - { - name: "https: url + token mapping", - // Use raw string literal for readability and to avoid long line joins. - rawYAML: []byte(`url: https://keg.example.com/@user/keg -token: secret123 -`), - wantSchema: kegpkg.SchemeHTTPs, - wantUrl: "https://keg.example.com/@user/keg", - wantHost: "keg.example.com", - wantPath: "/@user/keg", - wantToken: "secret123", - }, - { - name: "api: structured hub+namespace+kegName mapping pins the hub", - rawYAML: []byte("hub: jlr\nnamespace: jlrickert\nkegName: tapper\n"), - wantSchema: kegpkg.SchemeAlias, - wantHub: "jlr", - wantNamespace: "jlrickert", - wantKegName: "tapper", - }, - { - name: "api: canonical keg scalar (hub resolved from namespace)", - rawYAML: []byte("keg:@jlrickert/tapper"), - wantSchema: kegpkg.SchemeAlias, - wantHub: "", - wantNamespace: "jlrickert", - wantKegName: "tapper", - }, - { - name: "file: simple path", - rawYAML: []byte("/home/testuser/kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "/home/testuser/kegs/public", - }, - { - name: "file: with home expansion", - rawYAML: []byte("~/kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "~/kegs/public", - }, - { - name: "file: relative path", - rawYAML: []byte("../../kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "../../kegs/public", - }, - { - name: "file: screwy relative path", - rawYAML: []byte("..//../kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "../../kegs/public", - }, - { - name: "file: with explicit scheme", - rawYAML: []byte("file:///home/testuser/kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "/home/testuser/kegs/public", - }, - { - name: "file: path w/ explicit scheme and home", - rawYAML: []byte("file://~/kegs/public"), - wantSchema: kegpkg.SchemeFile, - wantFile: "~/kegs/public", - }, +func TestParseRejectsFilesystemAndUnsupportedTargets(t *testing.T) { + t.Parallel() + for _, raw := range []string{ + "/var/lib/kegs/docs", + "./docs", + "../docs", + "~/kegs/docs", + "C:\\kegs\\docs", + "file:///var/lib/kegs/docs", + "git://example.com/team/docs", + "ssh://example.com/team/docs", + "s3://bucket/docs", + "team/docs", + } { + t.Run(raw, func(t *testing.T) { + t.Parallel() + _, err := kegpkg.Parse(raw) + require.ErrorIs(t, err, kegpkg.ErrNotSupported) + }) } +} - for _, tc := range cases { +func TestTargetYAMLRemoteOnly(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + raw string + wantErr error + scheme string + hub string + namespace string + kegName string + token string + }{ + {name: "url scalar", raw: "https://hub.example.com/api/v1/@team/kegs/docs\n", scheme: kegpkg.SchemeHTTPs}, + {name: "url mapping", raw: "url: https://hub.example.com/api/v1/@team/kegs/docs\ntoken: secret\n", scheme: kegpkg.SchemeHTTPs, token: "secret"}, + {name: "keg scalar", raw: "keg:@team/docs\n", scheme: kegpkg.SchemeAlias, namespace: "team", kegName: "docs"}, + {name: "structured keg", raw: "hub: enterprise\nnamespace: team\nkegName: docs\n", scheme: kegpkg.SchemeAlias, hub: "enterprise", namespace: "team", kegName: "docs"}, + {name: "path scalar", raw: "/var/lib/kegs/docs\n", wantErr: kegpkg.ErrNotSupported}, + {name: "file uri", raw: "file:///var/lib/kegs/docs\n", wantErr: kegpkg.ErrNotSupported}, + {name: "file mapping", raw: "file: /var/lib/kegs/docs\n", wantErr: kegpkg.ErrNotSupported}, + } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - var kt kegpkg.Target - err := yaml.Unmarshal(tc.rawYAML, &kt) - if tc.wantErr { - require.Error(t, err, tc.name) + var target kegpkg.Target + err := yaml.Unmarshal([]byte(tc.raw), &target) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) return } require.NoError(t, err) - if tc.wantSchema != "" { - require.Equal(t, tc.wantSchema, kt.Scheme()) - } - if tc.wantFile != "" { - // Normalize the expected path to the current OS style before compare. - exp := filepath.FromSlash(tc.wantFile) - require.Equal(t, exp, kt.File) - } - if tc.wantHub != "" { - require.Equal(t, tc.wantHub, kt.Hub) - } - if tc.wantUrl != "" { - require.Equal(t, tc.wantUrl, kt.Url) - } - if tc.wantHost != "" { - require.Equal(t, tc.wantHost, kt.Host()) - } - if tc.wantPath != "" { - require.Equal(t, tc.wantPath, kt.Path()) - } - if tc.wantToken != "" { - require.Equal(t, tc.wantToken, kt.Token) - } - if tc.wantNamespace != "" { - require.Equal(t, tc.wantNamespace, kt.Namespace) - } - if tc.wantKegName != "" { - require.Equal(t, tc.wantKegName, kt.KegName) - } - // Ensure the String result is parseable as a URL when non-empty. - if kt.String() != "" { - _, err := url.Parse(kt.String()) - require.NoError(t, err, tc.name) - } + require.Equal(t, tc.scheme, target.Scheme()) + require.Equal(t, tc.hub, target.Hub) + require.Equal(t, tc.namespace, target.Namespace) + require.Equal(t, tc.kegName, target.KegName) + require.Equal(t, tc.token, target.Token) }) } } -func TestTargetExpand_ExpandsEnvironmentVariables(t *testing.T) { +func TestTargetExpandExpandsRemoteFields(t *testing.T) { t.Parallel() - jail := t.TempDir() - home := filepath.Join(string(filepath.Separator), "home", "tester") - env := toolkit.NewTestEnv(jail, home, "tester") + env := toolkit.NewTestEnv(t.TempDir(), "/home/tester", "tester") require.NoError(t, env.Set("KEG_NAME", "blog")) - require.NoError(t, env.Set("HUB_NAME", "knut")) + require.NoError(t, env.Set("HUB_NAME", "enterprise")) require.NoError(t, env.Set("SECRET_TOKEN", "secret-token")) require.NoError(t, env.Set("TOKEN_ENV_KEY", "TAPPER_TOKEN")) - kt := kegpkg.Target{ - File: "~/${KEG_NAME}/keg", + target := kegpkg.Target{ Url: "https://example.com/${USER}/${KEG_NAME}", Hub: "${HUB_NAME}", Password: "${SECRET_TOKEN}", Token: "${SECRET_TOKEN}", TokenEnv: "${TOKEN_ENV_KEY}", } - - err := kt.Expand(env) - require.NoError(t, err) - require.Equal(t, filepath.Join(home, "blog", "keg"), kt.File) - require.Equal(t, "https://example.com/tester/blog", kt.Url) - require.Equal(t, "knut", kt.Hub) - require.Equal(t, "secret-token", kt.Password) - require.Equal(t, "secret-token", kt.Token) - require.Equal(t, "TAPPER_TOKEN", kt.TokenEnv) + require.NoError(t, target.Expand(env)) + require.Equal(t, "https://example.com/tester/blog", target.Url) + require.Equal(t, "enterprise", target.Hub) + require.Equal(t, "secret-token", target.Password) + require.Equal(t, "secret-token", target.Token) + require.Equal(t, "TAPPER_TOKEN", target.TokenEnv) } -// TestParse_KegScheme_Canonicalization pins that the accepted input variants of -// the "keg:" scheme parse to the same hub-agnostic Target and round-trip -// through String() as the canonical "keg:@namespace/keg" form. The "@" sigil is -// stripped on parse so the stored namespace never carries it; Path() and -// String() re-apply it. The hub is resolved from the namespace, never encoded. -func TestParse_KegScheme_Canonicalization(t *testing.T) { +func TestNewKegFromTargetRejectsUnresolvedAndUnsupportedTargets(t *testing.T) { t.Parallel() + fx := NewSandbox(t) - variants := []string{ - "keg:@jlrickert/tapper", - "keg:/@jlrickert/tapper", - } - - for _, raw := range variants { - t.Run(raw, func(t *testing.T) { - t.Parallel() - kt, err := kegpkg.Parse(raw) - require.NoError(t, err) - require.Equal(t, kegpkg.SchemeAlias, kt.Scheme()) - require.Equal(t, "", kt.Hub, "the keg scheme never pins a hub") - require.Equal(t, "jlrickert", kt.Namespace, "@ sigil must be stripped on parse") - require.Equal(t, "tapper", kt.KegName) - require.Equal(t, "keg:@jlrickert/tapper", kt.String(), "String() must emit the canonical keg scheme") - require.Equal(t, filepath.Join("@jlrickert", "tapper"), kt.Path(), "Path() must re-apply @ exactly once") - }) - } + _, err := kegpkg.NewKegFromTarget(fx.Context(), kegpkg.Target{KegName: "docs", Namespace: "team"}, fx.Runtime()) + require.Error(t, err) + require.Contains(t, err.Error(), "no resolved hub url") - // A ":@ns/keg" form is not a keg reference — there is no such scheme. - // It is not classified as the keg scheme (it falls through to file/url). - t.Run("hub-prefixed form is not a keg reference", func(t *testing.T) { - t.Parallel() - kt, err := kegpkg.Parse("jlr:@jlrickert/tapper") - require.NoError(t, err) - require.NotEqual(t, kegpkg.SchemeAlias, kt.Scheme(), "a non-keg scheme prefix is not a keg reference") - }) + _, err = kegpkg.NewKegFromTarget(fx.Context(), kegpkg.Target{}, fx.Runtime()) + require.ErrorIs(t, err, kegpkg.ErrNotSupported) + require.True(t, errors.Is(err, kegpkg.ErrNotSupported)) } diff --git a/pkg/keg/testhelpers_internal_test.go b/pkg/keg/testhelpers_internal_test.go new file mode 100644 index 00000000..2520cbc7 --- /dev/null +++ b/pkg/keg/testhelpers_internal_test.go @@ -0,0 +1,7 @@ +package keg + +import "github.com/jlrickert/cli-toolkit/toolkit" + +func newTestMemoryRepo(rt *toolkit.Runtime) *testMemoryRepository { + return newTestMemoryRepository(rt) +} diff --git a/pkg/keg/testhelpers_test.go b/pkg/keg/testhelpers_test.go index 99377fd0..af2de770 100644 --- a/pkg/keg/testhelpers_test.go +++ b/pkg/keg/testhelpers_test.go @@ -6,12 +6,32 @@ import ( "testing" "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/internal/testkegrepo" "github.com/jlrickert/tapper/pkg/keg" ) //go:embed data/** var testdata embed.FS +func newTestMemoryRepo(rt *toolkit.Runtime) *testkegrepo.MemoryRepository { + return testkegrepo.NewMemoryRepository(rt) +} + +func memoryTarget(_ string, opts ...keg.TargetOption) keg.Target { + target := keg.Target{} + for _, apply := range opts { + apply(&target) + } + return target +} + +func newMemoryKegFromTarget(_ context.Context, target keg.Target, rt *toolkit.Runtime, _ ...keg.KegOption) (keg.Keg, error) { + k := keg.NewLocalKeg(newTestMemoryRepo(rt), rt) + k.SetTarget(&target) + return k, nil +} + func NewSandbox(t *testing.T, opts ...sandbox.Option) *sandbox.Sandbox { return sandbox.NewSandbox(t, &sandbox.Options{ Data: testdata, @@ -28,15 +48,33 @@ func initNonStrictTestKeg(t *testing.T, k keg.Keg, ctx context.Context) { if err := k.Init(ctx); err != nil { t.Fatalf("init test keg: %v", err) } - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { - t.Fatalf("read test keg config: %v", err) + t.Fatalf("read test keg settings: %v", err) } if cfg.SchemaPolicy == nil { cfg.SchemaPolicy = &keg.SchemaPolicy{} } cfg.SchemaPolicy.Strict = false - if err := k.SetConfig(ctx, []byte(cfg.String())); err != nil { + if err := k.SetSettings(ctx, []byte(cfg.String()), keg.SettingsWriteOptions{ExpectedHash: cfg.Hash()}); err != nil { t.Fatalf("disable strict test policy: %v", err) } } + +func removeOptions(t *testing.T, ctx context.Context, k keg.Keg, id keg.NodeId) keg.NodeRemoveOptions { + t.Helper() + view, err := k.ReadNode(ctx, id) + if err != nil { + t.Fatalf("read node %s before remove: %v", id.Path(), err) + } + return keg.NodeRemoveOptions{ID: id, ExpectedHash: view.Hash()} +} + +func moveOptions(t *testing.T, ctx context.Context, k keg.Keg, src, dst keg.NodeId) keg.NodeMoveOptions { + t.Helper() + view, err := k.ReadNode(ctx, src) + if err != nil { + t.Fatalf("read node %s before move: %v", src.Path(), err) + } + return keg.NodeMoveOptions{Source: src, Destination: dst, ExpectedHash: view.Hash()} +} diff --git a/pkg/mcp/flight_authority_validation_test.go b/pkg/mcp/flight_authority_validation_test.go deleted file mode 100644 index 4eb08911..00000000 --- a/pkg/mcp/flight_authority_validation_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package mcp_test - -import ( - "context" - "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" -) - -// The tests in this file validate the four session-authority contracts that -// `tap mcp` (stdio, config-driven) and the hosted HTTP endpoint -// (provider-driven) must both honour: -// -// 1. Both transports gate KEG work through the same session flight gate. -// 2. orient reads the current flight and refreshes it on every call. -// 3. Any denied KEG operation reports a permission error that sends the agent -// back to orient, because the usual cause is a flight edited mid-session. -// 4. A flight holding manage_flights can edit itself, and the edit governs the -// very next call without a reconnect. - -// restrictionNudge is the recovery instruction every cover and role-cap denial -// must carry. recoveryNudge is its counterpart for a session with no flight at -// all, which is worded for a reader who has nothing to refresh yet. -const ( - restrictionNudge = "Call `orient` to refresh this session's flight authority" - recoveryNudge = "then orient again" -) - -// newValidationSession builds a provider-driven session (the hosted shape) over -// a sandbox holding two real local kegs, so widening a cover mid-session can be -// observed against a keg that actually exists. -func newValidationSession(t *testing.T) (*sdkmcp.ClientSession, context.Context, *fakeSessionBackend) { - t.Helper() - ctx := context.Background() - sb := newTestSandbox(t) - rt := sb.Runtime() - - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) - _, err = tap.InitKeg(ctx, tapper.InitOptions{Keg: "other", Namespace: "local"}) - require.NoError(t, err) - - provider := newFakeSessionBackend() - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ - OrientationProvider: provider, FlightProvider: provider, - KegProvider: provider, IdentityProvider: provider, - }) - return connectFlightSession(t, ctx, srv, nil), ctx, provider -} - -func callCatKeg(t *testing.T, ctx context.Context, session *sdkmcp.ClientSession, keg string) *sdkmcp.CallToolResult { - t.Helper() - result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "cat", - Arguments: map[string]any{ - "keg": keg, - "node_ids": []string{"0"}, - "content_only": true, - }, - }) - require.NoError(t, err) - return result -} - -// --- requirement 3: denials send the agent back to orient ------------------ - -func TestMCP_UncoveredKegDenialNudgesReorientation_LocalSurface(t *testing.T) { - t.Parallel() - session, ctx, privateID := newFlightLockedSession(t) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "cat", - Arguments: map[string]any{ - "keg": "private", "node_ids": []string{privateID}, "content_only": true, - }, - }) - require.NoError(t, err) - require.True(t, res.IsError) - text := extractText(t, res) - require.Contains(t, text, `keg "@local/private" is not available in flight`) - require.Contains(t, text, restrictionNudge, - "a cover denial must send the agent back to orient; the flight may have changed mid-session") -} - -func TestMCP_UncoveredKegDenialNudgesReorientation_ProviderSurface(t *testing.T) { - t.Parallel() - session, ctx, _ := newValidationSession(t) - - res := callCatKeg(t, ctx, session, "@local/other") - require.True(t, res.IsError) - text := extractText(t, res) - require.Contains(t, text, `keg "@local/other" is not available in flight`) - require.Contains(t, text, restrictionNudge, - "the hosted surface must nudge re-orientation exactly like the stdio surface") -} - -func TestMCP_RoleCapDenialNudgesReorientation(t *testing.T) { - t.Parallel() - // +focused covers @local/personal at viewer, so a write is refused on the - // role cap rather than on cover membership. - session, ctx, _ := newFlightLockedSession(t) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "create", - Arguments: map[string]any{"keg": "personal", "nodes": []any{map[string]any{"key": "node", "title": "Blocked by role cap"}}}, - }) - require.NoError(t, err) - require.True(t, res.IsError) - text := extractText(t, res) - require.Contains(t, text, "viewer-only") - require.Contains(t, text, restrictionNudge, "a role-cap denial must nudge re-orientation too") -} - -// TestMCP_MidSessionFlightWideningDeniesUntilReorient is the scenario the nudge -// exists for: the flight gained a keg after this session pinned its authority, -// so the next call is denied even though the stored flight now permits it, and -// only orient repairs the session. -func TestMCP_MidSessionFlightWideningDeniesUntilReorient(t *testing.T) { - t.Parallel() - session, ctx, provider := newValidationSession(t) - - require.False(t, callCatKeg(t, ctx, session, "@local/personal").IsError) - require.True(t, callCatKeg(t, ctx, session, "@local/other").IsError) - - // Someone else widens the flight while this session is live. - cover := []tapper.FlightCover{ - {Namespace: "local", Keg: "personal", Role: tapper.FlightRoleEditor}, - {Namespace: "local", Keg: "other", Role: tapper.FlightRoleEditor}, - } - _, err := provider.UpdateFlight(ctx, tapper.UpdateFlightOptions{Ref: "+active", Cover: &cover}) - require.NoError(t, err) - - denied := callCatKeg(t, ctx, session, "@local/other") - require.True(t, denied.IsError, "the pinned snapshot still governs until orient") - require.Contains(t, extractText(t, denied), restrictionNudge) - - callOrient(t, ctx, session) - require.False(t, callCatKeg(t, ctx, session, "@local/other").IsError, - "orient must adopt the widened cover on the same connection") -} - -// --- requirement 4: manage_flights can modify its own flight --------------- - -func TestMCP_SelfEditWideningOwnCoverTakesEffectImmediately(t *testing.T) { - t.Parallel() - session, ctx, _ := newValidationSession(t) - require.True(t, callCatKeg(t, ctx, session, "@local/other").IsError) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{ - "ref": "+active", - "cover": []string{"@local/personal=editor", "@local/other=editor"}, - }}) - require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - - require.False(t, callCatKeg(t, ctx, session, "@local/other").IsError, - "a manage_flights self-edit must govern the next call without orient") - require.False(t, callCatKeg(t, ctx, session, "@local/personal").IsError) -} - -func TestMCP_WithoutManageFlightsSelfEditIsHiddenAndRefused(t *testing.T) { - t.Parallel() - session, ctx, provider := newValidationSession(t) - - provider.mu.Lock() - provider.active = "@local/+other" // +other carries no capabilities - provider.mu.Unlock() - callOrient(t, ctx, session) - - listed := listedToolNames(t, ctx, session) - require.NotContains(t, listed, "flight_create") - require.NotContains(t, listed, "flight_edit") - require.NotContains(t, listed, "flight_delete") - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{ - "ref": "+other", "instructions": "should not apply", - }}) - require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, extractText(t, res), "manage_flights") - - stored, err := provider.GetFlight(ctx, "+other") - require.NoError(t, err) - require.Equal(t, "other", stored.Instructions, "a refused self-edit must not persist") -} - -// --- requirements 1 + 2: both transports gate and recover identically ------ - -// TestMCP_BothSurfacesEnterTheSameRecoveryMode drives the config-driven and the -// provider-driven servers into a flight-less state by their own transport's -// mechanism — an emptied configuration versus a cleared account preference — -// and asserts they converge on one recovery contract. -func TestMCP_BothSurfacesEnterTheSameRecoveryMode(t *testing.T) { - recoveryTools := []string{"orient", "list_flights", "flight_show", "auth_info"} - - providerSession, providerCtx, provider := newValidationSession(t) - provider.mu.Lock() - provider.active = "" - provider.mu.Unlock() - require.Contains(t, callOrient(t, providerCtx, providerSession), "No KEGs are currently available") - require.ElementsMatch(t, recoveryTools, listedToolNames(t, providerCtx, providerSession)) - hosted := callCatKeg(t, providerCtx, providerSession, "@local/personal") - - localCtx, srv, rt := newOrientationServer(t, "") - localSession := connectFlightSession(t, localCtx, srv, nil) - writeProjectFlight(t, rt, "") - writeUserFlight(t, rt, "") - require.Contains(t, callOrient(t, localCtx, localSession), "No KEGs are currently available") - require.ElementsMatch(t, recoveryTools, listedToolNames(t, localCtx, localSession)) - local := callCat(t, localCtx, localSession) - - require.True(t, hosted.IsError) - require.True(t, local.IsError) - require.Equal(t, extractText(t, local), extractText(t, hosted), - "a flight-less session must read the same on both transports") - require.Contains(t, extractText(t, local), "no flight is selected") - require.Contains(t, extractText(t, local), recoveryNudge) -} diff --git a/pkg/mcp/orientation_revision_test.go b/pkg/mcp/orientation_revision_test.go new file mode 100644 index 00000000..037f65a4 --- /dev/null +++ b/pkg/mcp/orientation_revision_test.go @@ -0,0 +1,115 @@ +package mcp_test + +import ( + "testing" + + "github.com/jlrickert/tapper/pkg/mcp" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func revisionTestFlight(slug string, capabilities []tapper.FlightCapability, kegName, instructions string) *tapper.Flight { + return &tapper.Flight{ + Name: "@local/+" + slug, Namespace: "local", Slug: slug, Source: "test", + FlightManifest: tapper.FlightManifest{ + Visibility: tapper.FlightVisibilityPrivate, + Capabilities: append([]tapper.FlightCapability(nil), capabilities...), + Cover: []tapper.FlightCover{{Namespace: "local", Keg: kegName, Role: tapper.FlightRoleEditor}}, + Instructions: instructions, + }, + } +} + +func copyRevisionTestFlight(in *tapper.Flight) *tapper.Flight { + out := *in + out.Capabilities = append([]tapper.FlightCapability(nil), in.Capabilities...) + out.Cover = append([]tapper.FlightCover(nil), in.Cover...) + out.Subflights = append([]string(nil), in.Subflights...) + return &out +} + +func TestFinalizeOrientationHashesOnlyRelevantAuthority(t *testing.T) { + root := revisionTestFlight("root", nil, "personal", "root instructions") + root.Subflights = []string{"@local/+child"} + base := &mcp.Orientation{ + Root: root, Flight: root, Path: []string{root.Name}, Identity: `{"user_id":1}`, + Kegs: []tapper.OrientationKeg{{ + Ref: "@local/personal", Role: "admin", Visibility: "private", FlightCap: "editor", + Title: "Display title", Summary: "Display summary", Source: "atlas", + }}, + } + revision := func(in *mcp.Orientation) string { + require.NoError(t, mcp.FinalizeOrientation(in)) + return in.Revision + } + want := revision(base) + + displayOnly := *base + displayOnly.Revision = "" + displayOnly.Kegs = append([]tapper.OrientationKeg(nil), base.Kegs...) + displayOnly.Kegs[0].Title = "Renamed" + displayOnly.Kegs[0].Summary = "Changed summary" + displayOnly.Kegs[0].Source = "another-display-source" + require.Equal(t, want, revision(&displayOnly)) + + rootRelationOnly := *base + rootRelationOnly.Revision = "" + rootRelationOnly.Root = copyRevisionTestFlight(root) + rootRelationOnly.Flight = rootRelationOnly.Root + rootRelationOnly.Root.Subflights = nil + require.Equal(t, want, revision(&rootRelationOnly), "root-active sessions ignore child-list edits") + + roleChanged := *base + roleChanged.Revision = "" + roleChanged.Kegs = append([]tapper.OrientationKeg(nil), base.Kegs...) + roleChanged.Kegs[0].Role = "viewer" + require.NotEqual(t, want, revision(&roleChanged)) + + instructionsChanged := *base + instructionsChanged.Revision = "" + instructionsChanged.Root = copyRevisionTestFlight(root) + instructionsChanged.Flight = instructionsChanged.Root + instructionsChanged.Flight.Instructions = "changed authority instructions" + require.NotEqual(t, want, revision(&instructionsChanged)) +} + +func TestFinalizeOrientationChildIgnoresItsOwnSubflights(t *testing.T) { + root := revisionTestFlight("root", nil, "personal", "root") + root.Subflights = []string{"@local/+child"} + child := revisionTestFlight("child", []tapper.FlightCapability{tapper.FlightCapabilityManageKegs}, "other", "child") + child.Subflights = []string{"@local/+grandchild"} + base := &mcp.Orientation{Root: root, Flight: child, Path: []string{root.Name, child.Name}, Identity: "identity"} + require.NoError(t, mcp.FinalizeOrientation(base)) + + changed := *base + changed.Revision = "" + changed.Flight = copyRevisionTestFlight(child) + changed.Flight.Subflights = []string{"@local/+different"} + require.NoError(t, mcp.FinalizeOrientation(&changed)) + require.Equal(t, base.Revision, changed.Revision) + + changed.Revision = "" + changed.Flight.Instructions = "changed" + require.NoError(t, mcp.FinalizeOrientation(&changed)) + require.NotEqual(t, base.Revision, changed.Revision) + + changed = *base + changed.Revision = "" + changed.Path = []string{root.Name, "@local/+other-parent", child.Name} + require.NoError(t, mcp.FinalizeOrientation(&changed)) + require.NotEqual(t, base.Revision, changed.Revision, "selected canonical path edges are revision material") +} + +func TestCanonicalOrientationIdentityIsTransportNeutral(t *testing.T) { + local, err := mcp.CanonicalOrientationIdentity(mcp.AuthIdentity{ + Hub: "atlas-alias", UserID: 42, Username: "ada", DisplayName: "Ada", + DefaultNamespace: "ada", Namespaces: []string{"team", "ada"}, + }) + require.NoError(t, err) + hosted, err := mcp.CanonicalOrientationIdentity(mcp.AuthIdentity{ + Hub: "https://hub.example", UserID: 42, Username: "ada", DisplayName: "Ada", + DefaultNamespace: "ada", Namespaces: []string{"ada", "team"}, + }) + require.NoError(t, err) + require.Equal(t, local, hosted) +} diff --git a/pkg/mcp/precondition_read_test.go b/pkg/mcp/precondition_read_test.go new file mode 100644 index 00000000..b6af1a04 --- /dev/null +++ b/pkg/mcp/precondition_read_test.go @@ -0,0 +1,279 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func readNodeHash(t *testing.T, session *sdkmcp.ClientSession, ctx context.Context, nodeID string) string { + t.Helper() + read, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{"node_ids": []string{nodeID}}, + }) + require.NoError(t, err) + require.False(t, read.IsError, "cat failed: %s", extractText(t, read)) + rows := structuredNodeRows(t, read) + require.Len(t, rows, 1) + return rows[0].Hash +} + +func readSettingsHash(t *testing.T, session *sdkmcp.ClientSession, ctx context.Context, kegRef string) string { + t.Helper() + args := map[string]any{"minimal": false} + if kegRef != "" { + args["keg"] = kegRef + } + read, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_settings", Arguments: args}) + require.NoError(t, err) + require.False(t, read.IsError, "keg_settings failed: %s", extractText(t, read)) + return structuredHash(t, read) +} + +// structuredNodeRows decodes the per-node rows a read tool returns alongside +// its rendered text. +func structuredNodeRows(t *testing.T, res *sdkmcp.CallToolResult) []struct { + NodeID string `json:"node_id"` + Hash string `json:"hash"` + Content string `json:"content"` +} { + t.Helper() + require.NotNil(t, res.StructuredContent, "read tool returned no structured content") + raw, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + var payload struct { + Nodes []struct { + NodeID string `json:"node_id"` + Hash string `json:"hash"` + Content string `json:"content"` + } `json:"nodes"` + } + require.NoError(t, json.Unmarshal(raw, &payload)) + return payload.Nodes +} + +func structuredHash(t *testing.T, res *sdkmcp.CallToolResult) string { + t.Helper() + require.NotNil(t, res.StructuredContent, "read tool returned no structured content") + raw, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + var payload struct { + Hash string `json:"hash"` + } + require.NoError(t, json.Unmarshal(raw, &payload)) + return payload.Hash +} + +// TestPrecondition_CatHashRoundTripsThroughEdit is the contract Phase 1 exists +// to establish: the token a read hands out is exactly the token the matching +// write accepts, and a token from before someone else's write is refused. +// Without this an agent has no way to obtain the precondition a write demands. +func TestPrecondition_CatHashRoundTripsThroughEdit(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + createRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "create", + Arguments: batchCreateArgs(map[string]any{"title": "Precondition Subject"}), + }) + require.NoError(t, err) + require.False(t, createRes.IsError, "create failed: %s", extractText(t, createRes)) + nodeID := extractText(t, createRes) + + read, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{"node_ids": []string{nodeID}}, + }) + require.NoError(t, err) + require.False(t, read.IsError, "cat failed: %s", extractText(t, read)) + + rows := structuredNodeRows(t, read) + require.Len(t, rows, 1) + require.Equal(t, nodeID, rows[0].NodeID) + original := rows[0].Hash + require.NotEmpty(t, original, "cat must return a usable precondition token") + + missing, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "content": "# Missing token must fail\n", + }), + }) + require.NoError(t, err) + require.True(t, missing.IsError) + require.Contains(t, extractText(t, missing), "expected_hash") + require.Nil(t, missing.StructuredContent, "schema rejection must happen before the mutation handler") + + // The token a read handed out is accepted by the matching write. + editRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "content": "# Precondition Subject\n\nFirst writer wins.\n", + "expected_hash": original, + }), + }) + require.NoError(t, err) + require.False(t, editRes.IsError, "edit with a fresh hash must succeed: %s", extractText(t, editRes)) + + // The write moved the node, so the old token is now stale. + reread, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{"node_ids": []string{nodeID}}, + }) + require.NoError(t, err) + updated := structuredNodeRows(t, reread)[0].Hash + require.NotEmpty(t, updated) + require.NotEqual(t, original, updated, "a write must change the node's token") + + // A second agent still holding the pre-write token is refused rather than + // silently clobbering the first writer's change. + stale, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": nodeID, + "content": "# Precondition Subject\n\nSecond writer clobbers.\n", + "expected_hash": original, + }), + }) + require.NoError(t, err) + require.True(t, stale.IsError, "a stale hash must be refused, not applied") + staleStructured := structuredMap(t, stale) + require.Equal(t, "CONFLICT", staleStructured["code"]) + require.Equal(t, false, staleStructured["operationPerformed"]) + require.Equal(t, updated, staleStructured["currentHash"]) + require.Contains(t, staleStructured["currentContent"], "First writer wins.") + require.NotEmpty(t, staleStructured["action"]) + + // And the refused write left the first writer's content intact. + final, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{"node_ids": []string{nodeID}, "content_only": true}, + }) + require.NoError(t, err) + require.Contains(t, extractText(t, final), "First writer wins.") +} + +func TestPrecondition_RemoveBatchUsesDistinctTokensAtomically(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + create := func(title string) string { + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "create", + Arguments: batchCreateArgs(map[string]any{"title": title}), + }) + require.NoError(t, err) + require.False(t, result.IsError, extractText(t, result)) + return extractText(t, result) + } + one := create("Remove batch one") + two := create("Remove batch two") + oneHash := readNodeHash(t, session, ctx, one) + twoHash := readNodeHash(t, session, ctx, two) + require.NotEqual(t, oneHash, twoHash) + + missing, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "remove", + Arguments: map[string]any{"nodes": []map[string]any{ + {"node_id": one, "expected_hash": oneHash}, + {"node_id": two}, + }}, + }) + require.NoError(t, err) + require.True(t, missing.IsError) + require.Contains(t, extractText(t, missing), "expected_hash") + require.Nil(t, missing.StructuredContent) + require.NotEmpty(t, readNodeHash(t, session, ctx, one)) + require.NotEmpty(t, readNodeHash(t, session, ctx, two)) + + edit, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "edit", + Arguments: batchEditArgs(map[string]any{ + "node_id": two, + "content": "# Remove batch two\n\nchanged after the removal read\n", + "expected_hash": twoHash, + }), + }) + require.NoError(t, err) + require.False(t, edit.IsError, extractText(t, edit)) + currentTwoHash := readNodeHash(t, session, ctx, two) + require.NotEqual(t, twoHash, currentTwoHash) + + stale, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "remove", + Arguments: map[string]any{"nodes": []map[string]any{ + {"node_id": one, "expected_hash": oneHash}, + {"node_id": two, "expected_hash": twoHash}, + }}, + }) + require.NoError(t, err) + require.True(t, stale.IsError) + conflict := structuredMap(t, stale) + require.Equal(t, "CONFLICT", conflict["code"]) + require.Equal(t, false, conflict["operationPerformed"]) + require.Equal(t, currentTwoHash, conflict["currentHash"]) + require.NotEmpty(t, readNodeHash(t, session, ctx, one), "preflight conflict must not remove the first node") + require.Equal(t, currentTwoHash, readNodeHash(t, session, ctx, two)) + + valid, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "remove", + Arguments: map[string]any{"nodes": []map[string]any{ + {"node_id": one, "expected_hash": oneHash}, + {"node_id": two, "expected_hash": currentTwoHash}, + }}, + }) + require.NoError(t, err) + require.False(t, valid.IsError, extractText(t, valid)) + require.Contains(t, extractText(t, valid), "removed 2 node(s)") + + for _, nodeID := range []string{one, two} { + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "cat", + Arguments: map[string]any{"node_ids": []string{nodeID}}, + }) + require.NoError(t, err) + require.True(t, result.IsError, "node %s survived a valid removal", nodeID) + } +} + +func structuredMap(t *testing.T, result *sdkmcp.CallToolResult) map[string]any { + t.Helper() + require.NotNil(t, result.StructuredContent) + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +// TestPrecondition_ReadsExposeDocumentTokens covers the whole-document +// resources: schema definitions and keg settings each hand back the token +// their edit tool will require. +func TestPrecondition_ReadsExposeDocumentTokens(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + + settings, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "keg_settings", + Arguments: map[string]any{"minimal": false}, + }) + require.NoError(t, err) + require.False(t, settings.IsError, "keg_settings failed: %s", extractText(t, settings)) + require.NotEmpty(t, structuredHash(t, settings), "the full settings read must carry a token") + + // The minimal render is a cross-keg summary, not an editable document, so + // it deliberately hands back nothing to echo. + minimal, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "keg_settings", + Arguments: map[string]any{"minimal": true}, + }) + require.NoError(t, err) + require.False(t, minimal.IsError) + require.Nil(t, minimal.StructuredContent, "the minimal summary must not offer a write token") +} diff --git a/pkg/mcp/precondition_schema_test.go b/pkg/mcp/precondition_schema_test.go new file mode 100644 index 00000000..f863d259 --- /dev/null +++ b/pkg/mcp/precondition_schema_test.go @@ -0,0 +1,126 @@ +package mcp_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func listedToolSchemas(t *testing.T) map[string]map[string]any { + t.Helper() + session, ctx := newTestSession(t) + result, err := session.ListTools(ctx, nil) + require.NoError(t, err) + out := make(map[string]map[string]any, len(result.Tools)) + for _, tool := range result.Tools { + raw, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(raw, &schema)) + out[tool.Name] = schema + } + return out +} + +func resolveSchemaRef(t *testing.T, root, schema map[string]any) map[string]any { + t.Helper() + ref, _ := schema["$ref"].(string) + if ref == "" { + return schema + } + require.True(t, strings.HasPrefix(ref, "#/"), "unsupported schema ref %q", ref) + var current any = root + for _, raw := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + part := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + mapping, ok := current.(map[string]any) + require.True(t, ok, "schema ref %q traversed a non-object", ref) + current, ok = mapping[part] + require.True(t, ok, "schema ref %q is missing %q", ref, part) + } + resolved, ok := current.(map[string]any) + require.True(t, ok, "schema ref %q did not resolve to an object", ref) + return resolved +} + +func schemaProperty(t *testing.T, root, schema map[string]any, name string) map[string]any { + t.Helper() + schema = resolveSchemaRef(t, root, schema) + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok, "schema has no properties") + property, ok := properties[name].(map[string]any) + require.True(t, ok, "schema has no %q property", name) + return resolveSchemaRef(t, root, property) +} + +func schemaArrayItem(t *testing.T, root map[string]any, property string) map[string]any { + t.Helper() + array := schemaProperty(t, root, root, property) + items, ok := array["items"].(map[string]any) + require.True(t, ok, "%q has no item schema", property) + return resolveSchemaRef(t, root, items) +} + +func requireSchemaField(t *testing.T, schema map[string]any, field string, required bool) { + t.Helper() + values, _ := schema["required"].([]any) + if required { + require.Contains(t, values, field) + return + } + require.NotContains(t, values, field) +} + +func TestMCP_MutationSchemasRequireExpectedHashesAtResourceLocation(t *testing.T) { + t.Parallel() + schemas := listedToolSchemas(t) + + for _, tool := range []string{ + "keg_settings_edit", "move", "schema_edit", "schema_delete", "flight_edit", "flight_delete", + } { + schema, ok := schemas[tool] + require.True(t, ok, "missing tool %q", tool) + requireSchemaField(t, schema, "expected_hash", true) + } + + for tool, array := range map[string]string{ + "edit": "edits", "meta": "updates", "remove": "nodes", + } { + root, ok := schemas[tool] + require.True(t, ok, "missing tool %q", tool) + requireSchemaField(t, schemaArrayItem(t, root, array), "expected_hash", true) + } + + // Metadata reads use node_ids and never need a mutation token. Requiring + // expected_hash only inside updates keeps that read mode token-free. + meta := schemas["meta"] + requireSchemaField(t, meta, "expected_hash", false) +} + +func TestMCP_MutationDescriptionsTeachReadMergeRetryProtocol(t *testing.T) { + t.Parallel() + session, ctx := newTestSession(t) + result, err := session.ListTools(ctx, nil) + require.NoError(t, err) + + wants := map[string]string{ + "edit": "cat", "meta": "cat", "remove": "cat", "move": "cat", + "keg_settings_edit": "keg_settings", "schema_edit": "schema_read", + "schema_delete": "schema_read", "flight_edit": "flight_show", "flight_delete": "flight_show", + } + seen := map[string]bool{} + for _, tool := range result.Tools { + read, ok := wants[tool.Name] + if !ok { + continue + } + seen[tool.Name] = true + require.Contains(t, tool.Description, read) + require.Contains(t, strings.ToLower(tool.Description), "conflict") + require.Contains(t, strings.ToLower(tool.Description), "current hash") + } + for tool := range wants { + require.True(t, seen[tool], "missing tool description for %q", tool) + } +} diff --git a/pkg/mcp/providers.go b/pkg/mcp/providers.go index 4b101cd9..94370249 100644 --- a/pkg/mcp/providers.go +++ b/pkg/mcp/providers.go @@ -2,24 +2,50 @@ package mcp import ( "context" + "crypto/sha256" + "encoding/json" "errors" + "fmt" "sort" "strings" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" ) -// Orientation is one complete, immutable MCP authority candidate. +// Orientation is one complete MCP authority candidate. type Orientation struct { - Flight *tapper.Flight - Payload string - Kegs []tapper.OrientationKeg - Warnings []string + Root *tapper.Flight + Flight *tapper.Flight + Path []string + AvailableFlights []string + Identity string + Revision string + Payload string + Kegs []tapper.OrientationKeg + // AggregateKegs is the pinned root plus every accessible transitive + // descendant, merged by highest effective role. It exists only on a live + // per-call candidate and is never published into shared session state. + AggregateKegs []tapper.OrientationKeg + Warnings []string + // FullAccess marks an ungoverned no-flight candidate. It uses the identity's + // real KEG roles, publishes the complete tool inventory, and emits no Hub + // orientation header. + FullAccess bool + ReconnectInstructions string +} + +// FlightOrientationProvider refreshes one connection-pinned root and selects the root +// or an identity-accessible transitive descendant for one call. +type FlightOrientationProvider interface { + // Resolve reloads the pinned root's live graph and selects one flight for + // the current call; an empty selection resolves to the root. + Resolve(context.Context, string, string) (*Orientation, error) } // OrientationProvider owns transport-specific flight selection and rendering. type OrientationProvider interface { - // Load selects the active flight and renders it into a complete candidate. + // Load selects the pinned root and renders it into a complete candidate. // It is the transport's reload boundary: whatever "which flight am I on" // depends on is re-read here and nowhere else. Load(context.Context) (*Orientation, error) @@ -40,8 +66,7 @@ type FlightProvider interface { // CreateFlight persists a new flight and returns the stored manifest. CreateFlight(context.Context, tapper.CreateFlightOptions) (*tapper.Flight, error) // UpdateFlight applies a partial edit and returns the stored manifest. The - // return value is authoritative: a session editing its own flight adopts - // exactly these bytes. + // every subsequent authority-bearing call resolves the live graph again. UpdateFlight(context.Context, tapper.UpdateFlightOptions) (*tapper.Flight, error) // DeleteFlight removes a flight. DeleteFlight(context.Context, tapper.DeleteFlightOptions) error @@ -52,7 +77,7 @@ type FlightProvider interface { // operations answer to the same authenticated catalog. type KegDiscoveryProvider interface { // ListKegs returns every identity-authorized canonical keg ref. MCP applies - // the immutable active-flight cover before releasing results, so + // the call-selected flight cover before releasing results, so // implementations do not filter by flight themselves. ListKegs(context.Context) ([]string, error) // CreateKeg provisions a keg and returns its canonical @namespace/keg ref. @@ -62,6 +87,31 @@ type KegDiscoveryProvider interface { CreateKeg(context.Context, tapper.CreateKegOptions) (string, error) } +// 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"` + Summary string `json:"summary"` + Visibility string `json:"visibility"` + Source string `json:"source"` +} + +// KegSearchResult includes partial-discovery warnings without failing useful +// results from reachable hubs. +type KegSearchResult struct { + Kegs []KegSearchRow `json:"kegs"` + Warnings []string `json:"warnings,omitempty"` +} + +// 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 { @@ -73,6 +123,31 @@ type AuthIdentity struct { Namespaces []string `json:"namespaces"` } +// CanonicalOrientationIdentity returns transport-neutral revision material for +// the authenticated identity on the pinned root's Hub. Hub routing aliases and +// credentials are deliberately absent so stdio and hosted MCP hash the same +// authority while unrelated Hub logins cannot stale a call. +func CanonicalOrientationIdentity(identity AuthIdentity) (string, error) { + type revisionIdentity struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name,omitempty"` + DefaultNamespace string `json:"default_namespace"` + Namespaces []string `json:"namespaces"` + } + namespaces := append([]string(nil), identity.Namespaces...) + sort.Strings(namespaces) + raw, err := json.Marshal(revisionIdentity{ + UserID: identity.UserID, Username: identity.Username, + DisplayName: identity.DisplayName, DefaultNamespace: identity.DefaultNamespace, + Namespaces: namespaces, + }) + if err != nil { + return "", err + } + return string(raw), nil +} + // IdentityProvider reports who the session is authenticated as. type IdentityProvider interface { // Identities returns the authenticated identities, without credentials. @@ -86,45 +161,43 @@ type localOrientationProvider struct { staticFlight string } -// localBootstrapInstructions is the stdio half of the bootstrap nudge. `tap -// mcp` selects its flight from configuration the user owns, so recovery names -// config paths and CLI commands. skipped carries any hub that discovery could -// not reach: an unreachable hub is the most common reason a machine that does -// have flights reports none, and the reader cannot tell those apart otherwise. -func localBootstrapInstructions(skipped []string) string { +// localUnpinnedInstructions is the stdio nudge for an unpinned connection. +// The no-flight state stays active for the connection lifetime; configuration +// changes intentionally take effect only after the host starts a new session. +func localUnpinnedInstructions(skipped []string) string { var b strings.Builder - b.WriteString("No flight is configured for this machine.\n\n") + b.WriteString("This connection started without a configured flight, so normal identity-authorized full access applies.\n\n") if len(skipped) > 0 { - b.WriteString("Some hubs were skipped during discovery, so flights may exist that this\n") - b.WriteString("session cannot see. Resolve these before creating anything new:\n\n") + b.WriteString("Some hubs were skipped during discovery, so this projection may be incomplete:\n\n") for _, warning := range skipped { b.WriteString("- " + warning + "\n") } b.WriteString("\n") } - b.WriteString("To set this session up, ask the user to:\n\n") - b.WriteString("1. Run `tap bootstrap` if they have never configured tapper on this machine.\n") - b.WriteString("2. Create a flight — either a local manifest at `~/kegs/flights.d/.yaml`,\n") - b.WriteString(" or `tap flight create @/+` against a hub they are logged\n") - b.WriteString(" in to. `flight_create` here only works against a remote hub; local\n") - b.WriteString(" manifests must be written by hand.\n") - b.WriteString("3. Select it by setting `flight: +` in `~/.config/tapper/config.yaml`\n") + b.WriteString("Use this authority only to bootstrap a least-privilege root. Ask the user to:\n\n") + b.WriteString("1. Create a flight with `tap flight create @/+` against a hub\n") + b.WriteString(" they are logged in to. Give it only the KEG cover, roles, capabilities, and\n") + b.WriteString(" instructions needed.\n") + b.WriteString("2. Pin it outside MCP by setting `flight: +` in `~/.config/tapper/config.yaml`\n") b.WriteString(" (or the project's `.tapper/config.yaml`), exporting `TAP_FLIGHT=+`,\n") b.WriteString(" or passing `tap mcp --flight +`.\n") - b.WriteString("4. Tell you when that is done, so you can call `orient` again on this same\n") - b.WriteString(" connection. Flights are selected outside MCP; you cannot select one yourself.\n\n") - b.WriteString("`keg_create` works now if the user wants a KEG created first, but a KEG is\n") - b.WriteString("unreadable until a flight's cover names it.\n") + b.WriteString("3. Disconnect this MCP connection and start a new one. `session_refresh` cannot\n") + b.WriteString(" change this connection's no-flight authority.\n\n") + b.WriteString("`full_access` means the authenticated identities' existing access only; it never raises Hub ACLs or namespace membership.\n") return b.String() } +func localUnpinnedReconnect() string { + return "Create a least-privilege flight, pin it outside MCP with Tapper configuration or `tap mcp --flight`, then disconnect and start a new MCP connection; this connection remains on no-flight full access." +} + func (p *localOrientationProvider) Load(ctx context.Context) (*Orientation, error) { if p.tap == nil || p.tap.ConfigService == nil || p.tap.FlightService == nil { return nil, errors.New("Tapper flight service is unavailable") } // Adoption is the reload boundary for both session kinds: config-driven // sessions re-resolve their selection here, and launcher-bound sessions keep - // their immutable --flight but still need fresh hub routing and credentials. + // their connection-pinned --flight but still need fresh hub routing and credentials. // Configuration is otherwise fixed for the life of the process, so this is // where an edit made outside the session takes effect. p.tap.ConfigService.Reload() @@ -133,30 +206,356 @@ func (p *localOrientationProvider) Load(ctx context.Context) (*Orientation, erro ref = p.tap.ActiveFlightName("") } if strings.TrimSpace(ref) == "" { - // Nothing is selected. Whether the user can recover by picking one - // depends on whether anything exists to pick, so ask before choosing - // which of the two no-flight modes this session enters. - var warnings []string - flights, listErr := p.tap.ListFlights(ctx, tapper.ListFlightsOptions{Warnings: &warnings}) - if listErr == nil && len(flights) == 0 { - return p.Render(ctx, tapper.BootstrapFlight("", localBootstrapInstructions(warnings))) - } - payload, payloadErr := tapper.BuildOrientationPayload(nil, "", p.tap.ActiveAgentName(), nil, warnings) - return &Orientation{Payload: payload, Warnings: warnings}, payloadErr + return p.resolveUnpinned(ctx, "") } flight, err := p.tap.FlightService.GetFlightFresh(ctx, ref) if err != nil { return nil, err } - return p.Render(ctx, flight) + return p.resolve(ctx, flight, "") +} + +func (p *localOrientationProvider) Resolve(ctx context.Context, rootRef, selected string) (*Orientation, error) { + if p.tap == nil || p.tap.ConfigService == nil || p.tap.FlightService == nil { + return nil, errors.New("Tapper flight service is unavailable") + } + p.tap.ConfigService.Reload() + if strings.TrimSpace(rootRef) == "" { + return p.resolveUnpinned(ctx, selected) + } + root, err := p.tap.FlightService.GetFlightFresh(ctx, rootRef) + if err != nil { + if errors.Is(err, keg.ErrNotExist) || errors.Is(err, keg.ErrForbidden) || errors.Is(err, keg.ErrUnauthorized) { + return nil, fmt.Errorf("%w: launch root %q is no longer available: %v", ErrOrientationRootUnavailable, rootRef, err) + } + return nil, fmt.Errorf("%w: refresh launch root %q: %v", ErrOrientationUnavailable, rootRef, err) + } + return p.resolve(ctx, root, selected) +} + +func (p *localOrientationProvider) resolveUnpinned(ctx context.Context, selected string) (*Orientation, error) { + var warnings []string + available, err := p.tap.ListFlights(ctx, tapper.ListFlightsOptions{Warnings: &warnings}) + if err != nil { + return nil, fmt.Errorf("%w: list identity-accessible flights: %v", ErrOrientationUnavailable, err) + } + authorized, kegWarnings := p.tap.IdentityKegCatalog(ctx) + warnings = append(warnings, kegWarnings...) + if strings.TrimSpace(selected) == "" { + orientation := &Orientation{ + AvailableFlights: append([]string(nil), available...), Kegs: authorized, + AggregateKegs: append([]tapper.OrientationKeg(nil), authorized...), Warnings: warnings, + FullAccess: true, ReconnectInstructions: localUnpinnedReconnect(), + } + if err := FinalizeOrientation(orientation); err != nil { + return nil, err + } + authority := &tapper.OrientationAuthority{FullAccess: true, AvailableFlights: available, Revision: orientation.Revision} + payload, err := tapper.BuildOrientationPayload(nil, localUnpinnedInstructions(warnings), p.tap.ActiveAgentName(), authorized, warnings, authority) + if err != nil { + return nil, err + } + orientation.Payload = payload + return orientation, nil + } + active, loadErr := p.tap.FlightService.GetFlightFresh(ctx, selected) + if loadErr != nil { + return nil, fmt.Errorf("%w: selected flight %q is unavailable: %v", ErrOrientationDenied, selected, loadErr) + } + allowed := false + for _, ref := range available { + if ref == active.Name { + allowed = true + break + } + } + if !allowed { + return nil, fmt.Errorf("%w: selected flight %q is not identity-accessible", ErrOrientationDenied, selected) + } + kegs := tapper.ProjectOrientationKegs(active, authorized) + orientation := &Orientation{ + Root: active, Flight: active, Path: []string{active.Name}, AvailableFlights: append([]string(nil), available...), + Kegs: kegs, AggregateKegs: append([]tapper.OrientationKeg(nil), kegs...), Warnings: warnings, + } + orientation.Identity, err = p.orientationIdentityForSource(ctx, active.Source) + if err != nil { + return nil, err + } + if err := FinalizeOrientation(orientation); err != nil { + return nil, err + } + authority := &tapper.OrientationAuthority{ + Active: active, Path: orientation.Path, AvailableFlights: available, Revision: orientation.Revision, + FullAccess: true, + } + payload, err := tapper.BuildOrientationPayload(active, "", p.tap.ActiveAgentName(), kegs, warnings, authority) + if err != nil { + return nil, err + } + orientation.Payload = payload + return orientation, nil +} + +func (p *localOrientationProvider) orientationIdentityForSource(ctx context.Context, source string) (string, error) { + if source == "local" { + return "", nil + } + identities, err := (localIdentityProvider{tap: p.tap}).Identities(ctx) + if err != nil { + return "", fmt.Errorf("%w: load selected Hub identity: %v", ErrOrientationUnavailable, err) + } + cfg, err := p.tap.ConfigService.Config() + if err != nil { + return "", fmt.Errorf("%w: resolve selected Hub %q: %v", ErrOrientationUnavailable, source, err) + } + hubURL := tapper.CanonicalConfiguredHubURL(source) + if entry, ok := cfg.Hub(source); ok && strings.TrimSpace(entry.URL) != "" { + hubURL = tapper.CanonicalConfiguredHubURL(entry.URL) + } + for _, identity := range identities { + if tapper.CanonicalConfiguredHubURL(identity.Hub) != hubURL { + continue + } + canonical, err := CanonicalOrientationIdentity(identity) + if err != nil { + return "", fmt.Errorf("%w: canonicalize selected Hub identity: %v", ErrOrientationUnavailable, err) + } + return canonical, nil + } + return "", fmt.Errorf("%w: authenticated identity for selected Hub %q is unavailable", ErrOrientationUnavailable, source) +} + +func (p *localOrientationProvider) resolve(ctx context.Context, root *tapper.Flight, selected string) (*Orientation, error) { + graph, err := p.tap.FlightService.ResolveFlightGraph(ctx, root) + if err != nil { + return nil, fmt.Errorf("%w: load flight graph rooted at %s: %v", ErrOrientationUnavailable, root.Name, err) + } + root = graph.Root + active, path, err := graph.Select(selected) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrOrientationDenied, err) + } + authorized, warnings := p.tap.IdentityKegCatalog(ctx) + kegs := tapper.ProjectOrientationKegs(active, authorized) + aggregate := AggregateOrientationKegs(graphFlights(graph), authorized) + orientation := &Orientation{ + Root: root, Flight: active, Path: path, AvailableFlights: selectableFlightRefs(graph), + Kegs: kegs, AggregateKegs: aggregate, Warnings: warnings, + } + identities, err := (localIdentityProvider{tap: p.tap}).Identities(ctx) + if err != nil { + return nil, fmt.Errorf("%w: load root Hub identity: %v", ErrOrientationUnavailable, err) + } + var rootIdentity *AuthIdentity + rootHubURL := "" + if root.Source != "local" { + cfg, cfgErr := p.tap.ConfigService.Config() + if cfgErr != nil { + return nil, fmt.Errorf("%w: resolve root Hub %q: %v", ErrOrientationUnavailable, root.Source, cfgErr) + } + if entry, ok := cfg.Hub(root.Source); ok && strings.TrimSpace(entry.URL) != "" { + rootHubURL = tapper.CanonicalConfiguredHubURL(entry.URL) + } else { + // A source normally carries the configured alias. Retain support for + // older manifests that stored a URL directly. + rootHubURL = tapper.CanonicalConfiguredHubURL(root.Source) + } + } + for _, identity := range identities { + if root.Source != "local" && tapper.CanonicalConfiguredHubURL(identity.Hub) == rootHubURL { + matched := identity + rootIdentity = &matched + break + } + } + if rootIdentity != nil { + orientation.Identity, err = CanonicalOrientationIdentity(*rootIdentity) + if err != nil { + return nil, fmt.Errorf("%w: canonicalize root Hub identity: %v", ErrOrientationUnavailable, err) + } + } else if root.Source != "local" { + return nil, fmt.Errorf("%w: authenticated identity for root Hub %q is unavailable", ErrOrientationUnavailable, root.Source) + } + if err := FinalizeOrientation(orientation); err != nil { + return nil, err + } + authority := &tapper.OrientationAuthority{ + Root: root, Active: active, Path: path, AvailableFlights: orientation.AvailableFlights, + Revision: orientation.Revision, + } + discovery := kegs + if strings.TrimSpace(selected) == "" { + discovery = aggregate + } + payload, err := tapper.BuildOrientationPayload(active, "", p.tap.ActiveAgentName(), discovery, warnings, authority) + if err != nil { + return nil, err + } + orientation.Payload = payload + return orientation, nil } func (p *localOrientationProvider) Render(ctx context.Context, flight *tapper.Flight) (*Orientation, error) { - payload, kegs, warnings, err := p.tap.OrientationForFlight(ctx, flight) + authorized, warnings := p.tap.IdentityKegCatalog(ctx) + kegs := tapper.ProjectOrientationKegs(flight, authorized) + orientation := &Orientation{Root: flight, Flight: flight, Path: []string{flight.Name}, Kegs: kegs, AggregateKegs: append([]tapper.OrientationKeg(nil), kegs...), Warnings: warnings} + if err := FinalizeOrientation(orientation); err != nil { + return nil, err + } + authority := &tapper.OrientationAuthority{ + Root: flight, Active: flight, Path: orientation.Path, Revision: orientation.Revision, + } + payload, err := tapper.BuildOrientationPayload(flight, "", p.tap.ActiveAgentName(), kegs, warnings, authority) if err != nil { return nil, err } - return &Orientation{Flight: flight, Payload: payload, Kegs: kegs, Warnings: warnings}, nil + orientation.Payload = payload + return orientation, nil +} + +func graphFlights(graph *tapper.FlightGraph) []*tapper.Flight { + if graph == nil || graph.Root == nil { + return nil + } + out := make([]*tapper.Flight, 0, 1+len(graph.Available)) + out = append(out, graph.Root) + out = append(out, graph.Available...) + return out +} + +func selectableFlightRefs(graph *tapper.FlightGraph) []string { + if graph == nil || graph.Root == nil { + return nil + } + out := make([]string, 0, 1+len(graph.Available)) + out = append(out, graph.Root.Name) + out = append(out, graph.AvailableRefs()...) + return out +} + +// EffectiveOrientationRole intersects the identity's current ACL role with +// the selected flight's cover cap. full_access is represented by an admin cap, +// so it naturally contributes the identity role without widening it. +func EffectiveOrientationRole(row tapper.OrientationKeg) string { + return tapper.EffectiveOrientationRole(row) +} + +// AggregateOrientationKegs projects each reachable flight over one identity +// load and merges duplicate KEGs by highest effective role. +func AggregateOrientationKegs(flights []*tapper.Flight, authorized []tapper.OrientationKeg) []tapper.OrientationKeg { + best := map[string]tapper.OrientationKeg{} + for _, flight := range flights { + for _, row := range tapper.ProjectOrientationKegs(flight, authorized) { + current, exists := best[row.Ref] + rowRank := orientationRoleRank(EffectiveOrientationRole(row)) + currentRank := orientationRoleRank(EffectiveOrientationRole(current)) + if !exists || rowRank > currentRank { + if exists { + row.Flights = append(row.Flights, current.Flights...) + } + best[row.Ref] = row + continue + } + current.Flights = append(current.Flights, row.Flights...) + best[row.Ref] = current + } + } + out := make([]tapper.OrientationKeg, 0, len(best)) + for _, row := range best { + sort.Strings(row.Flights) + row.Flights = compactStrings(row.Flights) + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) + return out +} + +func compactStrings(values []string) []string { + if len(values) < 2 { + return values + } + out := values[:0] + for _, value := range values { + if len(out) == 0 || out[len(out)-1] != value { + out = append(out, value) + } + } + return out +} + +func orientationRoleRank(role string) int { + switch strings.TrimSpace(role) { + case string(tapper.FlightRoleAdmin): + return 3 + case string(tapper.FlightRoleEditor): + return 2 + default: + return 1 + } +} + +// FinalizeOrientation computes a deterministic revision when a provider has +// not supplied one. +func FinalizeOrientation(orientation *Orientation) error { + if orientation == nil || orientation.Revision != "" { + return nil + } + type flightAuthority struct { + Name string `json:"name"` + Visibility string `json:"visibility"` + Capabilities []tapper.FlightCapability `json:"capabilities"` + Cover []tapper.FlightCover `json:"cover"` + Instructions string `json:"instructions"` + } + type kegAuthority struct { + Ref string `json:"ref"` + Role string `json:"role"` + Visibility string `json:"visibility"` + FlightCap string `json:"flight_cap"` + } + type revisionInput struct { + RootRef string `json:"root_ref"` + Active flightAuthority `json:"active"` + Path []string `json:"path"` + Identity string `json:"identity"` + Kegs []kegAuthority `json:"kegs"` + } + in := revisionInput{ + Identity: orientation.Identity, Path: append([]string(nil), orientation.Path...), + Kegs: make([]kegAuthority, 0, len(orientation.Kegs)), + } + if orientation.Root != nil { + in.RootRef = orientation.Root.Name + } + if orientation.Flight != nil { + in.Active = flightAuthority{ + Name: orientation.Flight.Name, Visibility: orientation.Flight.Visibility, + Capabilities: append([]tapper.FlightCapability(nil), orientation.Flight.Capabilities...), + Cover: append([]tapper.FlightCover(nil), orientation.Flight.Cover...), + Instructions: orientation.Flight.Instructions, + } + sort.Slice(in.Active.Capabilities, func(i, j int) bool { return in.Active.Capabilities[i] < in.Active.Capabilities[j] }) + sort.Slice(in.Active.Cover, func(i, j int) bool { + left, right := in.Active.Cover[i], in.Active.Cover[j] + if left.Namespace != right.Namespace { + return left.Namespace < right.Namespace + } + if left.Keg != right.Keg { + return left.Keg < right.Keg + } + return left.Role < right.Role + }) + } + for _, row := range orientation.Kegs { + in.Kegs = append(in.Kegs, kegAuthority{Ref: row.Ref, Role: row.Role, Visibility: row.Visibility, FlightCap: row.FlightCap}) + } + sort.Slice(in.Kegs, func(i, j int) bool { return in.Kegs[i].Ref < in.Kegs[j].Ref }) + raw, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("marshal orientation revision: %w", err) + } + orientation.Revision = fmt.Sprintf("%x", sha256.Sum256(raw)) + return nil } type localFlightProvider struct{ tap *tapper.Tap } @@ -185,25 +584,51 @@ func (p localKegDiscoveryProvider) ListKegs(ctx context.Context) ([]string, erro func (p localKegDiscoveryProvider) CreateKeg(ctx context.Context, opts tapper.CreateKegOptions) (string, error) { target, err := p.tap.InitKeg(ctx, tapper.InitOptions{ - Keg: opts.Keg, - Namespace: opts.Namespace, - Title: opts.Title, - // MCP never prompts, and a config-driven create requires `tap bootstrap` - // exactly as keg resolution does (see resolveKegTarget). - NonInteractive: true, + Keg: opts.Keg, + Namespace: opts.Namespace, + Title: opts.Title, + Visibility: opts.Visibility, RequireBootstrap: true, }) if err != nil { return "", err } - // Visibility is a hub concept; a filesystem keg has no such column, so it is - // silently unused here rather than rejected. if ref := tapper.CanonicalKegRef(target); ref != "" { return ref, nil } return opts.Keg, nil } +func (p localKegDiscoveryProvider) SearchKegs(ctx context.Context, query string) (KegSearchResult, error) { + rows, warnings := p.tap.IdentityKegCatalog(ctx) + return KegSearchResult{Kegs: SearchIdentityKegs(rows, query), Warnings: warnings}, nil +} + +// SearchIdentityKegs performs case-insensitive literal matching over canonical +// ref, title, and summary, returning at most 50 canonically ordered rows. +func SearchIdentityKegs(rows []tapper.OrientationKeg, query string) []KegSearchRow { + needle := strings.ToLower(strings.TrimSpace(query)) + if needle == "" { + return nil + } + matched := make([]KegSearchRow, 0, len(rows)) + for _, row := range rows { + haystack := strings.ToLower(row.Ref + "\n" + row.Title + "\n" + row.Summary) + if !strings.Contains(haystack, needle) { + continue + } + matched = append(matched, KegSearchRow{ + Ref: row.Ref, Role: tapper.EffectiveOrientationRole(row), + Title: row.Title, Summary: row.Summary, Visibility: row.Visibility, Source: row.Source, + }) + } + sort.Slice(matched, func(i, j int) bool { return matched[i].Ref < matched[j].Ref }) + if len(matched) > 50 { + matched = matched[:50] + } + return matched +} + type localIdentityProvider struct{ tap *tapper.Tap } func (p localIdentityProvider) Identities(ctx context.Context) ([]AuthIdentity, error) { diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 0c76de0b..e03e5c18 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -3,12 +3,15 @@ package mcp import ( "context" "encoding/json" + "errors" + "fmt" "log/slog" "strings" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/jlrickert/cli-toolkit/clock" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -32,6 +35,7 @@ type ServerOptions struct { OrientationProvider OrientationProvider FlightProvider FlightProvider KegProvider KegDiscoveryProvider + KegSearchProvider KegSearchProvider IdentityProvider IdentityProvider // SharedFilesystem reports that this server and the agent host driving it // see the same filesystem. That holds for stdio (`tap mcp`), where a path in @@ -57,6 +61,9 @@ 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.IdentityProvider == nil { opt.IdentityProvider = localIdentityProvider{tap: tap} } @@ -94,7 +101,7 @@ func NewServer(tap *tapper.Tap, version string, defaults KegDefaults, opts ...Se registerLockTools(srv, tap, defaults) registerImportTools(srv, tap, defaults) registerFlightTools(srv, defaults, opt.FlightProvider) - registerKegTools(srv, defaults, opt.KegProvider) + registerKegTools(srv, defaults, opt.KegProvider, opt.KegSearchProvider) registerResourceTools(srv, tap, defaults) registerAuthInfoTool(srv, defaults, opt.IdentityProvider, opt.KegProvider) @@ -114,9 +121,6 @@ func resolveKegTarget(ctx context.Context, perToolKeg string, defaults KegDefaul out := defaults.KegTargetOptions if perToolKeg != "" { out.Keg = perToolKeg - out.Project = false - out.Cwd = false - out.Path = "" } if defaults.gate != nil { out.FlightContext = defaults.gate.activeFlight(ctx) @@ -194,6 +198,39 @@ func mcpDefaultMaxLines(maxLines int) int { // errorResult returns a CallToolResult with IsError set. func errorResult(err error) *sdkmcp.CallToolResult { + var restriction *tapper.FlightRestrictionError + if errors.As(err, &restriction) { + return orientationFailureResult(fmt.Errorf("%w: %v", ErrOrientationDenied, err)) + } + if errors.Is(err, ErrOrientationStale) || errors.Is(err, ErrOrientationDenied) || + errors.Is(err, ErrOrientationUnavailable) || errors.Is(err, ErrOrientationRootUnavailable) { + return orientationFailureResult(err) + } + var conflict *keg.PreconditionConflictError + if errors.As(err, &conflict) { + return &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: err.Error()}}, + StructuredContent: map[string]any{ + "code": keg.RemoteCodeConflict, + "operationPerformed": false, + "currentHash": conflict.CurrentHash, + "currentContent": string(conflict.CurrentContent), + "action": "read the current resource, merge the change, and retry with currentHash", + }, + IsError: true, + } + } + if errors.Is(err, keg.ErrPreconditionRequired) { + return &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: err.Error()}}, + StructuredContent: map[string]any{ + "code": keg.RemoteCodePreconditionRequired, + "operationPerformed": false, + "action": "read the resource and retry with its returned hash", + }, + IsError: true, + } + } return &sdkmcp.CallToolResult{ Content: []sdkmcp.Content{ &sdkmcp.TextContent{Text: err.Error()}, diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 9a94f063..f73e52f2 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -18,6 +18,8 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" + "github.com/jlrickert/tapper/internal/testkegrepo" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/mcp" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -44,6 +46,95 @@ func newTestSandbox(t *testing.T) *sandbox.Sandbox { ) } +func newMemoryTap(t *testing.T, ctx context.Context, rt *toolkit.Runtime) *tapper.Tap { + t.Helper() + if _, installed := orientationTestHubs.Load(rt); !installed { + installOrientationTestHub(t, rt) + writeUserFlight(t, rt, "") + } + hubURL := orientationTestHubFor(t, rt).server.URL + newKeg := func(alias string) (*keg.LocalKeg, error) { + repo := testkegrepo.NewMemoryRepository(rt) + local := keg.NewLocalKeg(repo, rt) + target := keg.NewApi("home", "local", alias, keg.WithHubURL(hubURL)) + local.SetTarget(&target) + if err := local.Init(ctx); err != nil { + return nil, err + } + if err := keg.UpdateSettings(ctx, local, func(settings *keg.Settings) { + settings.Title = strings.ToUpper(alias[:1]) + alias[1:] + " KEG" + if alias == "personal" { + settings.Title = "Personal KEG" + } + if settings.SchemaPolicy == nil { + settings.SchemaPolicy = &keg.SchemaPolicy{} + } + settings.SchemaPolicy.Strict = false + }); err != nil { + return nil, err + } + zero := keg.NodeId{ID: 0} + if err := local.SetContent(ctx, zero, []byte("# Personal Overview\n\nThis is the zero node of the personal KEG.\n")); err != nil { + return nil, err + } + zeroMeta, err := keg.ParseMeta(ctx, []byte("tags:\n - overview\n")) + if err != nil { + return nil, err + } + if err := local.SetMeta(ctx, zero, zeroMeta); err != nil { + return nil, err + } + created, err := local.Create(ctx, &keg.CreateOptions{ + Body: []byte("# Hello World\n\nA simple test node that links to [overview](../0).\n"), + Tags: []string{"test", "hello"}, + }) + if err != nil { + return nil, err + } + if created.ID.ID != 1 { + return nil, fmt.Errorf("seed node id = %d, want 1", created.ID.ID) + } + return local, nil + } + personal, err := newKeg("personal") + require.NoError(t, err) + kegs := map[string]keg.Keg{"personal": personal} + var kegsMu sync.Mutex + + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) + require.NoError(t, err) + store, err := tapper.LoadAuthStore(ctx, rt, tap.PathService.AuthStorePath()) + require.NoError(t, err) + store.Set(tapper.CanonicalHubURL(hubURL), tapper.AuthEntry{AccessToken: "test-token"}) + require.NoError(t, store.Save(ctx, rt, tap.PathService.AuthStorePath())) + tap.AuthValidateFn = func(context.Context, *toolkit.Runtime, string, string) (*tapper.WhoAmI, error) { + return &tapper.WhoAmI{UserID: 1, Username: "testuser", DefaultNamespace: "local", Namespaces: []string{"local"}}, nil + } + tap.KegResolver = func(_ context.Context, opts tapper.KegTargetOptions, _ tapper.FlightRole) (keg.Keg, error) { + alias := strings.TrimSpace(opts.Keg) + if alias == "" { + alias = "personal" + } + if strings.HasPrefix(alias, "@") { + if _, tail, ok := strings.Cut(alias, "/"); ok { + alias = tail + } + } + kegsMu.Lock() + defer kegsMu.Unlock() + if existing := kegs[alias]; existing != nil { + return existing, nil + } + created, err := newKeg(alias) + if err != nil { + return nil, err + } + kegs[alias] = created + return created, nil + } + return tap +} + func newTestSessionWithOpts(t *testing.T, opts ...mcp.ServerOptions) (*sdkmcp.ClientSession, context.Context) { t.Helper() ctx := context.Background() @@ -51,12 +142,9 @@ func newTestSessionWithOpts(t *testing.T, opts ...mcp.ServerOptions) (*sdkmcp.Cl sb := newTestSandbox(t) rt := sb.Runtime() - tap, err := tapper.NewTap(tapper.TapOptions{ - Runtime: rt, - }) - require.NoError(t, err) + tap := newMemoryTap(t, ctx, rt) - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+test"}}, opts...) + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, opts...) serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() // Connect server in background. @@ -101,12 +189,9 @@ func newTestSessionWithRuntime(t *testing.T, opts ...mcp.ServerOptions) (*sdkmcp sb := newTestSandbox(t) rt := sb.Runtime() - tap, err := tapper.NewTap(tapper.TapOptions{ - Runtime: rt, - }) - require.NoError(t, err) + tap := newMemoryTap(t, ctx, rt) - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+test"}}, opts...) + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, opts...) serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() // Connect server in background. @@ -144,11 +229,11 @@ func TestMCP_ToolsList(t *testing.T) { names[tool.Name] = true } for _, want := range []string{ - "auth_info", "keg_list", "cat", "list", "grep", "tags", "backlinks", "links", "info", + "auth_info", "keg_list", "keg_search", "cat", "list", "grep", "tags", "backlinks", "links", "info", "keg_settings", "keg_settings_edit", "stats", "create", "edit", "meta", "remove", "move", "index", "list_indexes", "index_cat", "doctor", "node_history", "node_snapshot", "node_snapshot_view", "node_restore", "list_files", "list_images", "delete_file", "delete_image", - "upload_file", "upload_image", "download_image", "orient", "import_from_keg", + "upload_file", "upload_image", "download_image", "orient", "session_refresh", "import_from_keg", "lock_acquire", "lock_release", "lock_status", "lock_force_release", "list_flights", "flight_show", "flight_create", "flight_edit", "flight_delete", "schema_list", "schema_read", "schema_create", "schema_edit", "schema_delete", "validate", @@ -190,11 +275,11 @@ func TestMCP_CommonAgentSafeSurface(t *testing.T) { "keg_settings_edit", "stats", "create", "edit", "meta", "remove", "move", "index", "list_indexes", "index_cat", "node_history", "node_snapshot", - "node_snapshot_view", "node_restore", "orient", + "node_snapshot_view", "node_restore", "orient", "session_refresh", "list_files", "list_images", "delete_file", "delete_image", "upload_file", "upload_image", "download_image", "schema_list", "schema_read", "schema_create", "schema_edit", - "schema_delete", "validate", "doctor", "import_from_keg", "keg_list", "auth_info", + "schema_delete", "validate", "doctor", "import_from_keg", "keg_list", "keg_search", "auth_info", "lock_acquire", "lock_release", "lock_status", "lock_force_release", "list_flights", "flight_show", "flight_create", "flight_edit", "flight_delete", } { @@ -232,15 +317,18 @@ func TestMCP_Cat(t *testing.T) { func TestMCP_KegSettingsEdit_ReplacesValidatedDocument(t *testing.T) { t.Parallel() session, ctx := newTestSession(t) + expectedHash := readSettingsHash(t, session, ctx, "") edit, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "keg_settings_edit", Arguments: map[string]any{ - "data": "kegv: 2025-07\ntitle: Agent Edited\nsummary: complete replacement\n", + "data": "kegv: 2025-07\ntitle: Agent Edited\nsummary: complete replacement\n", + "expected_hash": expectedHash, }, }) require.NoError(t, err) require.False(t, edit.IsError, "keg_settings_edit returned error: %v", edit.Content) + callOrient(t, ctx, session) read, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "keg_settings", @@ -537,6 +625,7 @@ func TestMCPMutationSchemasRejectLegacySingleItemFields(t *testing.T) { {"create", map[string]any{"title": "legacy"}}, {"edit", map[string]any{"node_id": "0", "content": "# legacy\n"}}, {"meta", map[string]any{"node_id": "0"}}, + {"remove", map[string]any{"node_ids": []string{"0"}, "expected_hash": "legacy"}}, {"node_snapshot", map[string]any{"node_id": "0"}}, } { res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: tc.name, Arguments: tc.args}) @@ -549,9 +638,11 @@ func TestMCPMutationSchemasRejectEmptyAndOversizedArrays(t *testing.T) { session, ctx := newTestSession(t) oversizedObjects := make([]any, 101) oversizedIDs := make([]any, 101) + oversizedRemovals := make([]any, 101) for i := range oversizedObjects { oversizedObjects[i] = map[string]any{"key": fmt.Sprintf("node-%d", i)} oversizedIDs[i] = "0" + oversizedRemovals[i] = map[string]any{"node_id": fmt.Sprintf("%d", i), "expected_hash": "hash"} } for _, tc := range []struct { name string @@ -565,6 +656,8 @@ func TestMCPMutationSchemasRejectEmptyAndOversizedArrays(t *testing.T) { {"meta read oversized", map[string]any{"node_ids": oversizedIDs}}, {"meta update empty", map[string]any{"updates": []any{}}}, {"meta update oversized", map[string]any{"updates": oversizedObjects}}, + {"remove empty", map[string]any{"nodes": []any{}}}, + {"remove oversized", map[string]any{"nodes": oversizedRemovals}}, {"snapshot empty", map[string]any{"nodes": []any{}}}, {"snapshot oversized", map[string]any{"nodes": oversizedObjects}}, } { @@ -580,9 +673,10 @@ func TestMCPMutationSchemasRejectEmptyAndOversizedArrays(t *testing.T) { func TestMCPMutationsPreserveAgentSchemaPolicy(t *testing.T) { session, ctx := newTestSession(t) + expectedHash := readSettingsHash(t, session, ctx, "") settings, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "keg_settings_edit", - Arguments: map[string]any{"data": `kegv: 2025-07 + Arguments: map[string]any{"expected_hash": expectedHash, "data": `kegv: 2025-07 schemaPolicy: strict: true human: off @@ -592,6 +686,7 @@ schemaPolicy: }) require.NoError(t, err) require.False(t, settings.IsError, "settings update failed: %s", extractText(t, settings)) + callOrient(t, ctx, session) schema, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "schema_create", Arguments: map[string]any{"data": `type: task @@ -726,13 +821,15 @@ func TestMCP_Edit(t *testing.T) { require.NoError(t, err) nodeID := extractText(t, createRes) require.False(t, createRes.IsError) + expectedHash := readNodeHash(t, session, ctx, nodeID) // Edit it. editRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "edit", Arguments: batchEditArgs(map[string]any{ - "node_id": nodeID, - "content": "# After Edit\n\nEdited via MCP.\n", + "node_id": nodeID, + "content": "# After Edit\n\nEdited via MCP.\n", + "expected_hash": expectedHash, }), }) require.NoError(t, err) @@ -782,13 +879,15 @@ func TestMCP_MetaWrite(t *testing.T) { }) require.NoError(t, err) nodeID := extractText(t, createRes) + expectedHash := readNodeHash(t, session, ctx, nodeID) // Write new metadata. writeRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "meta", Arguments: batchMetaArgs(map[string]any{ - "node_id": nodeID, - "content": "tags:\n - updated\n - mcp\n", + "node_id": nodeID, + "content": "tags:\n - updated\n - mcp\n", + "expected_hash": expectedHash, }), }) require.NoError(t, err) @@ -820,12 +919,16 @@ func TestMCP_Remove(t *testing.T) { }) require.NoError(t, err) nodeID := extractText(t, createRes) + expectedHash := readNodeHash(t, session, ctx, nodeID) // Remove it. removeRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "remove", Arguments: map[string]any{ - "node_ids": []string{nodeID}, + "nodes": []map[string]any{{ + "node_id": nodeID, + "expected_hash": expectedHash, + }}, }, }) require.NoError(t, err) @@ -855,13 +958,15 @@ func TestMCP_Move(t *testing.T) { }) require.NoError(t, err) srcID := extractText(t, createRes) + expectedHash := readNodeHash(t, session, ctx, srcID) // Move it to ID 999. moveRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "move", Arguments: map[string]any{ - "source_id": srcID, - "dest_id": "999", + "source_id": srcID, + "dest_id": "999", + "expected_hash": expectedHash, }, }) require.NoError(t, err) @@ -1350,7 +1455,7 @@ func TestMCP_RepoInitMissingAlias(t *testing.T) { // --- import tool tests --- -func TestMCP_ToolsList_IncludesImportTool(t *testing.T) { +func TestMCP_ToolsList_ExcludesArchiveImportAndKeepsKegImport(t *testing.T) { t.Parallel() session, ctx := newTestSession(t) @@ -1363,6 +1468,7 @@ func TestMCP_ToolsList_IncludesImportTool(t *testing.T) { } require.Contains(t, names, "import_from_keg") + require.NotContains(t, names, "import") } func TestMCP_ImportFromKeg(t *testing.T) { @@ -1902,125 +2008,6 @@ func TestMCP_DownloadFileNotFound(t *testing.T) { // --- archive tool tests --- -func TestMCP_ToolsList_IncludesArchiveTools(t *testing.T) { - t.Skip("archive tools are not part of the agent-safe MCP surface") - t.Parallel() - session, ctx := newTestSession(t) - - res, err := session.ListTools(ctx, nil) - require.NoError(t, err) - - names := make([]string, len(res.Tools)) - for i, tool := range res.Tools { - names[i] = tool.Name - } - - require.Contains(t, names, "export") - require.Contains(t, names, "import") -} - -func TestMCP_ExportAndImport(t *testing.T) { - t.Skip("archive tools are not part of the agent-safe MCP surface") - t.Parallel() - session, ctx := newTestSession(t) - - // Export the default keg. - exportRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "export", - Arguments: map[string]any{ - "output_path": "~/export-test.tar.gz", - }, - }) - require.NoError(t, err) - text := extractText(t, exportRes) - require.False(t, exportRes.IsError, "export returned error: %s", text) - require.Contains(t, text, "exported to") - - // Create a second keg to import into. - initRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "repo_init", - Arguments: map[string]any{ - "keg": "importtarget", - "user": true, - "title": "Import Target", - }, - }) - require.NoError(t, err) - require.False(t, initRes.IsError, "repo_init returned error: %s", extractText(t, initRes)) - - // Import the archive into the second keg. - importRes, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "import", - Arguments: map[string]any{ - "keg": "importtarget", - "path": "~/export-test.tar.gz", - }, - }) - require.NoError(t, err) - importText := extractText(t, importRes) - require.False(t, importRes.IsError, "import returned error: %s", importText) - require.Contains(t, importText, "imported") - require.Contains(t, importText, "node(s)") -} - -func TestMCP_ExportMissingPath(t *testing.T) { - t.Skip("archive tools are not part of the agent-safe MCP surface") - t.Parallel() - session, ctx := newTestSession(t) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "export", - Arguments: map[string]any{ - "output_path": "", - }, - }) - require.NoError(t, err) - require.True(t, res.IsError, "expected error for empty output path") -} - -func TestMCP_ImportMissingFile(t *testing.T) { - t.Skip("archive tools are not part of the agent-safe MCP surface") - t.Parallel() - session, ctx := newTestSession(t) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "import", - Arguments: map[string]any{ - "path": "~/nonexistent-archive.tar.gz", - }, - }) - require.NoError(t, err) - require.True(t, res.IsError, "expected error for missing archive file") -} - -// --- graph tool tests --- - -// TestMCP_GraphToolIsDisabled pins the deprecation. graph rendered a standalone -// HTML page that an agent cannot display, so returning it as tool text spent -// context on markup nobody reads. `tap graph --output` still serves the case -// that works; the tool stays off MCP until the feature is removed outright. -func TestMCP_GraphToolIsDisabled(t *testing.T) { - t.Parallel() - session, ctx := newTestSession(t) - - res, err := session.ListTools(ctx, nil) - require.NoError(t, err) - - names := make([]string, len(res.Tools)) - for i, tool := range res.Tools { - names[i] = tool.Name - } - require.NotContains(t, names, "graph") - - called, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ - Name: "graph", - Arguments: map[string]any{}, - }) - if err == nil { - require.True(t, called.IsError, "graph must not be callable") - } -} - func extractText(t *testing.T, res *sdkmcp.CallToolResult) string { t.Helper() var parts []string diff --git a/pkg/mcp/session_agent_flight_test.go b/pkg/mcp/session_agent_flight_test.go index 238527e1..51e220cc 100644 --- a/pkg/mcp/session_agent_flight_test.go +++ b/pkg/mcp/session_agent_flight_test.go @@ -9,46 +9,33 @@ import ( "github.com/stretchr/testify/require" "github.com/jlrickert/tapper/pkg/mcp" - "github.com/jlrickert/tapper/pkg/tapper" ) -// TestMCP_AgentFlightMovesWithConfig is the regression this whole mechanism -// exists for. `tap launch` used to export the agent's flight as TAP_FLIGHT, so -// a running session could never leave it: env outranks project and user config, -// and a process cannot change its own environment. Editing the agent's flight -// and re-orienting silently did nothing, while the session reported success. -// -// Exporting TAP_AGENT instead makes the flight a reference resolved on every -// orientation, so the edit lands. -func TestMCP_AgentFlightMovesWithConfig(t *testing.T) { +func TestMCP_AgentFlightDoesNotMoveConnectionPinnedRoot(t *testing.T) { ctx, srv, rt := newAgentOrientationServer(t, "qwen") session := connectFlightSession(t, ctx, srv, nil) - require.Contains(t, session.InitializeResult().Instructions, "+alpha") - require.Contains(t, session.InitializeResult().Instructions, "Alpha instructions") + requireConnectionInstructions(t, session.InitializeResult().Instructions) writeAgentFlight(t, rt, "qwen", "beta") oriented := callOrient(t, ctx, session) - require.Contains(t, oriented, "+beta") - require.Contains(t, oriented, "Beta instructions") - require.NotContains(t, oriented, "Alpha instructions") + require.Contains(t, oriented, "+baseline") + require.Contains(t, oriented, "Baseline instructions") + require.NotContains(t, oriented, "Beta instructions") } -// The payload names the agent, so a reader who wants a different flight is told -// where the current one came from instead of being pointed at a `flight:` key -// the agent silently outranks. func TestMCP_AgentIsNamedInTheOrientationPayload(t *testing.T) { ctx, srv, _ := newAgentOrientationServer(t, "qwen") session := connectFlightSession(t, ctx, srv, nil) oriented := callOrient(t, ctx, session) require.Contains(t, oriented, "agent `qwen`") - require.Contains(t, oriented, "call `orient` again") + require.Contains(t, oriented, "model and telemetry identity") + require.Contains(t, oriented, "cannot select or replace") } -// A direct TAP_FLIGHT still wins, which is the escape hatch for overriding a -// launched session without touching config. +// A direct TAP_FLIGHT pins the launch root independently of TAP_AGENT. func TestMCP_TapFlightOverridesTheAgentInSession(t *testing.T) { ctx, srv, _ := newAgentOrientationServerWithEnv(t, map[string]string{ "TAP_AGENT": "qwen", @@ -59,16 +46,13 @@ func TestMCP_TapFlightOverridesTheAgentInSession(t *testing.T) { require.Contains(t, callOrient(t, ctx, session), "+baseline") } -// A stale agent name is reported in the payload rather than locking the -// session: the agent cannot edit its own environment to fix it. -func TestMCP_UnknownAgentWarnsButKeepsTheSessionUsable(t *testing.T) { +func TestMCP_UnknownAgentDoesNotAffectFlightAuthority(t *testing.T) { ctx, srv, _ := newAgentOrientationServer(t, "ghost") session := connectFlightSession(t, ctx, srv, nil) oriented := callOrient(t, ctx, session) - require.Contains(t, oriented, `agent "ghost"`) - require.Contains(t, oriented, "not configured") - // The user baseline still governs, so KEG tools stay available. + require.Contains(t, oriented, "agent `ghost`") + require.NotContains(t, oriented, "not configured") require.Contains(t, oriented, "+baseline") require.False(t, callCat(t, ctx, session).IsError) } @@ -78,35 +62,37 @@ func newAgentOrientationServer(t *testing.T, agent string) (context.Context, *sd return newAgentOrientationServerWithEnv(t, map[string]string{"TAP_AGENT": agent}) } -// newAgentOrientationServer builds a config-driven session (no static flight) -// whose flight comes from an agent, mirroring what `tap launch` produces. +// newAgentOrientationServer builds a config-driven session where TAP_AGENT is +// independent of the user-configured root. func newAgentOrientationServerWithEnv(t *testing.T, env map[string]string) (context.Context, *sdkmcp.Server, *toolkit.Runtime) { t.Helper() ctx := context.Background() sb := newTestSandbox(t) require.NoError(t, sb.Setwd("/home/testuser/project")) rt := sb.Runtime() + installOrientationTestHub(t, rt) for k, v := range env { require.NoError(t, rt.Env().Set(k, v)) } writeFlight(t, rt, "baseline", "Baseline instructions") writeFlight(t, rt, "alpha", "Alpha instructions") writeFlight(t, rt, "beta", "Beta instructions") - // The user baseline is what an unknown or flightless agent falls back to. + // Legacy per-agent flight values are intentionally ignored. writeAgentFlight(t, rt, "qwen", "alpha") - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) + tap := newMemoryTap(t, ctx, rt) srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}) return ctx, srv, rt } -// writeAgentFlight rewrites the user config so agent `name` points at +slug, -// keeping the baseline `flight:` underneath it to prove the agent outranks it. +// writeAgentFlight rewrites a legacy per-agent flight while retaining the +// independent baseline root. func writeAgentFlight(t *testing.T, rt *toolkit.Runtime, name, slug string) { t.Helper() - body := "defaultKeg: personal\nfallbackNamespace: local\n" + - "hubs:\n home:\n kind: local\n basePath: ~/kegs\n" + + hub := orientationTestHubFor(t, rt) + body := "defaultKeg: personal\nfallbackHub: home\nfallbackNamespace: local\ndisableAtlasHub: true\n" + + "namespaces:\n local:\n hub: home\n" + + "hubs:\n home:\n kind: remote\n url: " + hub.server.URL + "\n tokenEnv: TAPPER_TEST_HUB_TOKEN\n" + "flight: +baseline\n" + "agents:\n " + name + ":\n model: ollama/qwen3.6:35b\n flight: +" + slug + "\n" require.NoError(t, rt.AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", []byte(body), 0o644)) diff --git a/pkg/mcp/session_bootstrap_test.go b/pkg/mcp/session_bootstrap_test.go index 42d3f3c7..522fc90e 100644 --- a/pkg/mcp/session_bootstrap_test.go +++ b/pkg/mcp/session_bootstrap_test.go @@ -13,23 +13,8 @@ import ( "github.com/jlrickert/tapper/pkg/tapper" ) -// A session that can reach no flights at all runs on the synthetic bootstrap -// flight instead of the select-a-flight recovery mode: telling a user to pick -// from an empty list is a dead end, so the session instead carries the -// authority to create the first flight and the first KEG. - -// bootstrapTools is every tool a bootstrap session may see. The bootstrap -// flight carries both manage_flights and manage_kegs, so nothing is filtered -// out by capability on top of the allowlist. -var bootstrapTools = []string{ - "orient", "list_flights", "flight_show", "auth_info", - "flight_create", "flight_edit", "flight_delete", "keg_create", -} - // flightSection returns the "## Flight" block of an orientation payload, which -// is where the session declares its own mode. Assertions must scope to it: the -// canonical guidance appended to every payload also describes bootstrap and -// recovery, so a whole-payload substring check passes in every mode. +// is where the session declares its own mode. func flightSection(t *testing.T, payload string) string { t.Helper() _, rest, ok := strings.Cut(payload, "## Flight\n") @@ -38,74 +23,139 @@ func flightSection(t *testing.T, payload string) string { return section } -// newBootstrapSession builds the stdio surface over a configured but -// flight-less machine: the hub's basePath points at a directory with no -// flights.d, so discovery legitimately reports zero flights. -func newBootstrapSession(t *testing.T) (*sdkmcp.ClientSession, context.Context, *toolkit.Runtime) { +// newNoFlightSession builds the stdio surface over an authenticated remote Hub +// with no flights, so discovery legitimately reports zero flights. +func newNoFlightSession(t *testing.T) (*sdkmcp.ClientSession, context.Context, *toolkit.Runtime) { t.Helper() ctx := context.Background() sb := newTestSandbox(t) require.NoError(t, sb.Setwd("/home/testuser")) rt := sb.Runtime() - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(`defaultKeg: personal -fallbackNamespace: local -hubs: - home: - kind: local - defaultNamespace: local - basePath: ~/empty-kegs -`), 0o644) - - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) + installOrientationTestHub(t, rt) + writeUserFlight(t, rt, "") + tap := newMemoryTap(t, ctx, rt) srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}) return connectFlightSession(t, ctx, srv, nil), ctx, rt } -func TestMCP_NoFlightsAnywhereEntersBootstrapMode(t *testing.T) { +func TestMCP_NoFlightsAnywhereUsesIdentityFullAccess(t *testing.T) { t.Parallel() - session, ctx, _ := newBootstrapSession(t) + session, ctx, _ := newNoFlightSession(t) - payload := session.InitializeResult().Instructions + requireConnectionInstructions(t, session.InitializeResult().Instructions) + payload := callOrient(t, ctx, session) flight := flightSection(t, payload) - require.Contains(t, flight, "temporary bootstrap flight") - require.NotContains(t, flight, "recovery mode", - "bootstrap must not present itself as the select-a-flight recovery mode") - require.Contains(t, flight, "tap bootstrap") + require.Contains(t, flight, "No flight was provided") + require.Contains(t, flight, "identity-authorized full access") require.Contains(t, flight, "TAP_FLIGHT", "the stdio surface must nudge toward configuration, not a web UI") - require.Equal(t, payload, callOrient(t, ctx, session), "orient is idempotent in bootstrap") + require.Contains(t, flight, "start a new one") + require.Equal(t, payload, callOrient(t, ctx, session), "orient is read-only and idempotent") + + tools := listedToolNames(t, ctx, session) + require.Contains(t, tools, "cat") + require.Contains(t, tools, "create") + require.Contains(t, tools, "flight_create") + require.Contains(t, tools, "keg_create") + search, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": "anything"}}) + require.NoError(t, err) + require.False(t, search.IsError, extractText(t, search)) + + created, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ + "keg": "first", "namespace": "local", "title": "First KEG", + }}) + require.NoError(t, err) + require.False(t, created.IsError, extractText(t, created)) + require.False(t, callCatKeg(t, ctx, session, "@local/first").IsError, + "no-flight full access must expose a newly created KEG at the identity's real role") +} - require.ElementsMatch(t, bootstrapTools, listedToolNames(t, ctx, session)) +// Two governed states have a nil session flight: failed-root recovery, which +// reaches nothing, and no-flight identity authority, which reaches everything +// the identity reaches. auth_info used to treat both as "no flight, no KEGs" and +// so reported an empty list in a session that could read them all — directly +// contradicting keg_list on the same connection. +func TestMCP_NoFlightAuthInfoReportsIdentityKegs(t *testing.T) { + t.Parallel() + session, ctx, _ := newNoFlightSession(t) - denied := callCatKeg(t, ctx, session, "@local/personal") - require.True(t, denied.IsError, "an empty cover still denies every KEG") - require.Contains(t, extractText(t, denied), "bootstrap flight") + created, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ + "keg": "first", "namespace": "local", "title": "First KEG", + }}) + require.NoError(t, err) + require.False(t, created.IsError, extractText(t, created)) + + listed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, listed.IsError, extractText(t, listed)) + require.Contains(t, extractText(t, listed), "@local/first") + + info, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "auth_info", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, info.IsError, extractText(t, info)) + require.Contains(t, extractText(t, info), "@local/first", + "auth_info must not report zero KEGs while keg_list reports them on the same connection") } -// TestMCP_FlightsExistButUnselectedStaysInSelectMode guards the boundary -// between the two no-flight modes: a machine that has flights must keep asking -// the user to pick one rather than handing the agent admin authority. -func TestMCP_FlightsExistButUnselectedStaysInSelectMode(t *testing.T) { +func TestMCP_FlightsExistButUnselectedUsesFullAccessAndExactSelection(t *testing.T) { ctx, srv, rt := newOrientationServer(t, "") - session := connectFlightSession(t, ctx, srv, nil) writeProjectFlight(t, rt, "") writeUserFlight(t, rt, "") + session := connectFlightSession(t, ctx, srv, nil) flight := flightSection(t, callOrient(t, ctx, session)) - require.Contains(t, flight, "recovery mode") - require.NotContains(t, flight, "bootstrap flight") - require.ElementsMatch(t, - []string{"orient", "list_flights", "flight_show", "auth_info"}, - listedToolNames(t, ctx, session)) + require.Contains(t, flight, "No flight was provided") + require.Contains(t, flight, "@local/+alpha") + require.Contains(t, listedToolNames(t, ctx, session), "cat") + + explicit := orientCall(t, session, ctx, map[string]any{"flight": "@local/+alpha"}) + require.Contains(t, explicit, "Alpha instructions") + require.NotContains(t, explicit, "No flight was provided") + require.Contains(t, explicit, "Launch root: (none; identity-authorized full access)") + require.Contains(t, explicit, "Selected flight: `@local/+alpha`") + + denied, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{ + "flight": "@other/+missing", + }}) + require.NoError(t, err) + require.True(t, denied.IsError) + require.Contains(t, extractText(t, denied), "ORIENTATION_DENIED") + + type outcome struct { + flight string + text string + err error + } + results := make(chan outcome, 2) + for _, selected := range []string{"@local/+alpha", "@local/+beta"} { + selected := selected + go func() { + res, callErr := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{"flight": selected}}) + text := "" + if res != nil { + text = extractText(t, res) + } + results <- outcome{flight: selected, text: text, err: callErr} + }() + } + for range 2 { + got := <-results + require.NoError(t, got.err) + if strings.HasSuffix(got.flight, "+alpha") { + require.Contains(t, got.text, "Alpha instructions") + require.NotContains(t, got.text, "Beta instructions") + } else { + require.Contains(t, got.text, "Beta instructions") + require.NotContains(t, got.text, "Alpha instructions") + } + } + require.Contains(t, flightSection(t, callOrient(t, ctx, session)), "No flight was provided", + "concurrent explicit selections must not replace no-flight authority") } -// TestMCP_BootstrapCreatesFirstKegThenAdoptsItsFlight walks the whole recovery -// the bootstrap instructions describe: create the KEG over MCP, have the user -// write and select a flight covering it, then orient into a working session. -func TestMCP_BootstrapCreatesFirstKegThenAdoptsItsFlight(t *testing.T) { +func TestMCP_NoFlightStaysPinnedAndNewSessionAdoptsConfiguredFlight(t *testing.T) { t.Parallel() - session, ctx, rt := newBootstrapSession(t) + session, ctx, rt := newNoFlightSession(t) res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ "keg": "first", "namespace": "local", "title": "First KEG", @@ -114,73 +164,43 @@ func TestMCP_BootstrapCreatesFirstKegThenAdoptsItsFlight(t *testing.T) { require.False(t, res.IsError, extractText(t, res)) require.Contains(t, extractText(t, res), "@local/first") - require.True(t, callCatKeg(t, ctx, session, "@local/first").IsError, - "creating a KEG does not add it to the active flight's cover") + require.False(t, callCatKeg(t, ctx, session, "@local/first").IsError) - // The user does the part MCP deliberately cannot: write a flight and select it. - require.NoError(t, rt.AtomicWriteFile("/home/testuser/empty-kegs/flights.d/first.yaml", - []byte("title: First\ncover:\n - namespace: local\n keg: first\n role: editor\n"), 0o644)) - require.NoError(t, rt.AtomicWriteFile("/home/testuser/.config/tapper/config.yaml", - []byte("defaultKeg: personal\nfallbackNamespace: local\nflight: +first\nhubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: ~/empty-kegs\n"), 0o644)) + // The user does the part MCP deliberately cannot: select the created flight + // in normal Tapper configuration. + orientationTestHubFor(t, rt).putFlight(tapper.HubFlight{ + Namespace: "local", Slug: "first", Title: "First", Visibility: tapper.FlightVisibilityPrivate, + Cover: []tapper.HubFlightCover{{Namespace: "local", Keg: "first", Role: "editor"}}, + }) + writeUserFlight(t, rt, "first") - flight := flightSection(t, callOrient(t, ctx, session)) - require.Contains(t, flight, "+first") - require.NotContains(t, flight, "temporary bootstrap flight") - require.False(t, callCatKeg(t, ctx, session, "@local/first").IsError, - "orient must adopt the flight the user just selected") - require.NotContains(t, listedToolNames(t, ctx, session), "keg_create", - "a real flight without manage_kegs does not inherit bootstrap's authority") + beforeRefresh := flightSection(t, callOrient(t, ctx, session)) + require.Contains(t, beforeRefresh, "No flight was provided", + "orient must not replace the connection-pinned no-flight state") + refreshed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, refreshed.IsError, extractText(t, refreshed)) + require.Equal(t, "already_active", refreshed.StructuredContent.(map[string]any)["status"]) + require.Equal(t, false, refreshed.StructuredContent.(map[string]any)["toolsChanged"]) + require.Equal(t, "new_session", refreshed.StructuredContent.(map[string]any)["nextAction"]) + require.Contains(t, flightSection(t, callOrient(t, ctx, session)), "No flight was provided") + + tap := newMemoryTap(t, ctx, rt) + newSession := connectFlightSession(t, ctx, mcp.NewServer(tap, "test", mcp.KegDefaults{}), nil) + flight := flightSection(t, callOrient(t, ctx, newSession)) + require.Contains(t, flight, "@local/+first") + require.NotContains(t, flight, "No flight was provided") + require.False(t, callCatKeg(t, ctx, newSession, "@local/first").IsError) } -// TestMCP_LocalFlightCreateReportsNotImplemented pins the honest failure for -// the one thing bootstrap cannot do on a local-only machine. -func TestMCP_LocalFlightCreateReportsNotImplemented(t *testing.T) { +func TestMCP_NoFlightCreatesFlightThroughRemoteHub(t *testing.T) { t.Parallel() - session, ctx, _ := newBootstrapSession(t) + session, ctx, _ := newNoFlightSession(t) res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_create", Arguments: map[string]any{ "ref": "@local/+attempt", "cover": []string{"@local/first=editor"}, }}) require.NoError(t, err) - require.True(t, res.IsError) - text := extractText(t, res) - require.Contains(t, text, "not implemented for local hubs") - require.Contains(t, text, "flights.d/attempt.yaml", - "the refusal must name the manifest the user should write instead") -} - -func TestMCP_KegCreateRequiresManageKegs(t *testing.T) { - t.Parallel() - session, ctx, provider := newValidationSession(t) - - // +active grants manage_flights only. - require.NotContains(t, listedToolNames(t, ctx, session), "keg_create") - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ - "keg": "blocked", "namespace": "local", - }}) - require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, extractText(t, res), "manage_kegs") - - provider.mu.Lock() - require.Empty(t, provider.createdKegs, "a refused keg_create must not reach the provider") - provider.mu.Unlock() - - capabilities := []tapper.FlightCapability{ - tapper.FlightCapabilityManageFlights, tapper.FlightCapabilityManageKegs, - } - _, err = provider.UpdateFlight(ctx, tapper.UpdateFlightOptions{Ref: "+active", Capabilities: &capabilities}) - require.NoError(t, err) - callOrient(t, ctx, session) - - require.Contains(t, listedToolNames(t, ctx, session), "keg_create") - res, err = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{ - "keg": "allowed", "namespace": "local", - }}) - require.NoError(t, err) require.False(t, res.IsError, extractText(t, res)) - - provider.mu.Lock() - require.Equal(t, []string{"@local/allowed"}, provider.createdKegs) - provider.mu.Unlock() + require.Contains(t, extractText(t, res), "@local/+attempt") } diff --git a/pkg/mcp/session_flight.go b/pkg/mcp/session_flight.go index 4d85c85e..4c545e2d 100644 --- a/pkg/mcp/session_flight.go +++ b/pkg/mcp/session_flight.go @@ -2,16 +2,34 @@ package mcp import ( "context" + "encoding/json" "errors" "fmt" + "strings" "sync" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" ) -var errMCPFlightRequired = errors.New("no flight is selected; KEG tools are locked. Inspect flights through MCP with `list_flights` and `flight_show`, ask the user to select a flight in Tapper configuration, then orient again") +var errMCPFlightRequired = errors.New("the explicitly configured flight could not be activated; KEG tools are locked. Inspect flights with `list_flights` and `flight_show`, repair that exact selection outside MCP, then call `session_refresh` and `orient`") + +// ErrOrientationStale is returned without performing the requested operation. +var ErrOrientationStale = fmt.Errorf("ORIENTATION_STALE: authority changed between per-call resolution and dispatch; retry the operation yourself after reviewing current authority. Mutations are never replayed automatically: %w", keg.ErrOrientationStale) + +// ErrOrientationDenied reports a fresh orientation that lacks the requested +// authority. Selecting a different accessible flight is explicit per call. +var ErrOrientationDenied = fmt.Errorf("ORIENTATION_DENIED: the requested flight is not selectable from this connection's current authority or does not grant this operation. The operation was not performed: %w", keg.ErrOrientationDenied) + +// ErrOrientationUnavailable reports a transient failure to recompute live +// authority. The caller may retry later, but the operation is never replayed. +var ErrOrientationUnavailable = fmt.Errorf("ORIENTATION_UNAVAILABLE: current orientation authority could not be verified; retry after the Hub is available. The operation was not performed: %w", keg.ErrOrientationUnavailable) + +// ErrOrientationRootUnavailable reports permanent loss of the connection-pinned root. +// A different root requires a newly launched session. +var ErrOrientationRootUnavailable = fmt.Errorf("ORIENTATION_ROOT_UNAVAILABLE: the connection-pinned root was deleted or is no longer accessible; start a new session to choose a different root. The operation was not performed: %w", keg.ErrOrientationRootUnavailable) // failedOrientationPayload describes a selection that was made but could not be // resolved. It deliberately does not reuse errMCPFlightRequired: reporting "no @@ -20,51 +38,66 @@ var errMCPFlightRequired = errors.New("no flight is selected; KEG tools are lock // is usually a wrong flight name or an unreachable hub. func failedOrientationPayload(err error) string { return "This session could not establish flight authority: " + err.Error() + - "\n\nKEG tools are locked until it does. Call `list_flights` to see what" + + "\n\nKEG tools are locked until it does, so only `orient`, `session_refresh`, `list_flights`," + + " `flight_show`, `auth_info`, and `keg_search` are published. An empty cover on a" + + " successfully loaded flight would still publish the complete registered" + + " inventory. Call `list_flights` to see what" + " actually exists, then ask the user to correct the selected flight in" + - " Tapper configuration and call `orient` again on this same connection." + + " Tapper configuration, then call `session_refresh`, then `orient` on this same connection." + " An empty flight list usually means this machine is not bootstrapped or" + " not authenticated to the hub that hosts the flight." } var recoveryToolNames = map[string]bool{ - "orient": true, - "list_flights": true, - "flight_show": true, - "auth_info": true, -} - -// bootstrapToolNames is what a session running on the synthetic bootstrap -// flight may call. Its cover is empty, so the KEG tools would fail anyway; -// hiding them keeps the agent from spending the session discovering that one -// refusal at a time. Like recoveryToolNames this is an allowlist, so a KEG tool -// added later is hidden by default rather than leaking into bootstrap. -var bootstrapToolNames = map[string]bool{ - "orient": true, - "list_flights": true, - "flight_show": true, - "auth_info": true, - "flight_create": true, - "flight_edit": true, - "flight_delete": true, - "keg_create": true, + "orient": true, + "session_refresh": true, + "list_flights": true, + "flight_show": true, + "auth_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{ + "auth_info": true, "auth_status": true, "keg_search": 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, "list_flights": true, "flight_show": true, } // sessionMode is the authority state of one MCP session. type sessionMode int const ( - // modeActive: a real flight governs the session. + // modeActive: either no-flight identity authority or a real flight governs + // the session. modeActive sessionMode = iota - // modeSelect: no flight is selected but flights exist to select. The agent - // cannot fix this itself, so only the recovery tools are offered. + // modeSelect: an explicitly configured flight failed to activate. The agent + // cannot change configuration itself, so only recovery tools are offered. modeSelect - // modeBootstrap: no flight exists at all. A synthetic admin flight governs - // the session so the agent can create the first flight and keg. - modeBootstrap ) -var errMCPBootstrapOnly = errors.New("no flight is configured; this session is running on a temporary bootstrap flight and the KEG tools are locked. Create the first flight and KEG with `flight_create` and `keg_create`, ask the user to select the flight, then call `orient` again") +const toolListTransitionMarker = "_session_tool_list_transition_marker" + +// initializationInstructions carries the static KEG operating rules plus the +// directive to orient. The rules are here so a caller that has already been +// told which flight to pass — a subagent briefed by a coordinator, say — can +// work without spending a round trip on orientation it does not need. They do +// not carry session state and cannot go stale. +// +// The directive still matters: only orient reports the flight, its cover, and +// the available KEGs, and only orient survives a context reset, because these +// instructions are captured once at connection and are never re-sent. +func initializationInstructions() string { + // The rules already say to orient first and to orient again after a context + // reset; this adds only what they do not: what orient is for. + return tapper.OrientationOperatingRules() + + "\nCall `orient` for this session's current authority, instructions, and available KEGs.\n" +} type flightSessionContextKey struct{} @@ -72,16 +105,25 @@ type flightSessionContextKey struct{} // pointer at their boundary, so an in-flight call finishes under the authority // with which it began while later calls observe a successful refresh. type orientationContext struct { - flight *tapper.Flight - payload string - kegs []tapper.OrientationKeg - warnings []string - mode sessionMode + root *tapper.Flight + flight *tapper.Flight + path []string + availableFlights []string + identity string + revision string + payload string + kegs []tapper.OrientationKeg + aggregateKegs []tapper.OrientationKeg + warnings []string + fullAccess bool + reconnect string + mode sessionMode } type flightSessionState struct { - mu sync.RWMutex - current *orientationContext + mu sync.RWMutex + refreshMu sync.Mutex + current *orientationContext } type sessionFlightGate struct { @@ -90,7 +132,6 @@ type sessionFlightGate struct { mu sync.Mutex states map[string]*flightSessionState srv *sdkmcp.Server - calls sync.RWMutex } func newSessionFlightGate(provider OrientationProvider) *sessionFlightGate { @@ -122,57 +163,161 @@ func (g *sessionFlightGate) current(sessionID string) *orientationContext { return state.current } -func (g *sessionFlightGate) refresh(ctx context.Context, sessionID string) (*orientationContext, error) { - g.calls.Lock() - defer g.calls.Unlock() +// loadAndPin establishes no-flight authority or a real root for a new session, +// and retries the exact configured root for recovery. Once active, ordinary +// calls never publish a call-local selection into shared state. +func (g *sessionFlightGate) loadAndPin(ctx context.Context, sessionID string) (*orientationContext, error) { + state := g.state(sessionID) + state.refreshMu.Lock() + defer state.refreshMu.Unlock() + current := g.current(sessionID) + if currentMode(current) == modeActive { + return current, nil + } candidate, err := g.provider.Load(ctx) if err != nil { - // A failed explicit refresh retains the last valid authority. - if current := g.current(sessionID); current != nil { + if current != nil { return current, err } // Initialization must remain connectable for recovery. recovery := &orientationContext{payload: failedOrientationPayload(err), mode: modeSelect, warnings: []string{err.Error()}} - state := g.state(sessionID) state.mu.Lock() state.current = recovery state.mu.Unlock() return recovery, err } + next, err := makeOrientationContext(candidate) + if err != nil { + return current, err + } + // Aggregate authority is intentionally call-local. The pinned session keeps + // only root context for auth_info and future live resolutions. + next.aggregateKegs = nil + g.publish(sessionID, next, false) + return next, nil +} + +type sessionRefreshOutput struct { + Status string `json:"status"` + Root string `json:"root,omitempty"` + ToolsChanged bool `json:"toolsChanged"` + NextAction string `json:"nextAction,omitempty"` +} + +func (g *sessionFlightGate) refresh(ctx context.Context, sessionID string) (sessionRefreshOutput, error) { + state := g.state(sessionID) + state.refreshMu.Lock() + defer state.refreshMu.Unlock() + + current := g.current(sessionID) + if currentMode(current) == modeActive { + nextAction := "orient" + if current.fullAccess { + nextAction = "new_session" + } + root := "" + if current.root != nil { + root = current.root.Name + } + return sessionRefreshOutput{ + Status: "already_active", Root: root, + ToolsChanged: false, NextAction: nextAction, + }, nil + } + + candidate, err := g.provider.Load(ctx) + if err != nil { + return sessionRefreshOutput{}, err + } + next, err := makeOrientationContext(candidate) + if err != nil { + return sessionRefreshOutput{}, err + } + if next.fullAccess { + return sessionRefreshOutput{}, fmt.Errorf( + "the failed configured flight cannot fall back to no-flight full access on this connection; start a new MCP connection", + ) + } + next.aggregateKegs = nil + toolsChanged := toolSurfaceChanged(current, next) + g.publish(sessionID, next, toolsChanged) + + switch currentMode(next) { + case modeActive: + return sessionRefreshOutput{ + Status: "activated", Root: next.root.Name, + ToolsChanged: toolsChanged, NextAction: "orient", + }, nil + default: + return sessionRefreshOutput{Status: "selection_required", ToolsChanged: toolsChanged}, nil + } +} + +// resolveCall computes live graph, identity, and selected authority for one +// invocation. It never mutates session state. +func (g *sessionFlightGate) resolveCall(ctx context.Context, sessionID, selected string) (*orientationContext, error) { + pinned := g.current(sessionID) + if currentMode(pinned) != modeActive || (!pinned.fullAccess && pinned.root == nil) { + return nil, lockedError(pinned) + } + resolver, ok := g.provider.(FlightOrientationProvider) + if !ok { + return nil, fmt.Errorf("%w: per-call flight selection is unavailable for this orientation provider", ErrOrientationUnavailable) + } + rootRef := "" + if pinned.root != nil { + rootRef = pinned.root.Name + } + candidate, err := resolver.Resolve(ctx, rootRef, selected) + if err != nil { + return nil, err + } + return makeOrientationContext(candidate) +} + +func makeOrientationContext(candidate *Orientation) (*orientationContext, error) { if candidate == nil { candidate = &Orientation{} } + if candidate.Revision == "" { + if err := FinalizeOrientation(candidate); err != nil { + return nil, err + } + } next := &orientationContext{ - flight: cloneFlight(candidate.Flight), - payload: candidate.Payload, - kegs: append([]tapper.OrientationKeg(nil), candidate.Kegs...), - warnings: append([]string(nil), candidate.Warnings...), - mode: modeFor(candidate.Flight), + root: cloneFlight(candidate.Root), flight: cloneFlight(candidate.Flight), + path: append([]string(nil), candidate.Path...), + availableFlights: append([]string(nil), candidate.AvailableFlights...), + identity: candidate.Identity, revision: candidate.Revision, payload: candidate.Payload, + kegs: append([]tapper.OrientationKeg(nil), candidate.Kegs...), + aggregateKegs: append([]tapper.OrientationKeg(nil), candidate.AggregateKegs...), + warnings: append([]string(nil), candidate.Warnings...), fullAccess: candidate.FullAccess, + reconnect: candidate.ReconnectInstructions, mode: modeFor(candidate), + } + if next.root == nil { + next.root = cloneFlight(candidate.Flight) } - g.publish(sessionID, next) return next, nil } -// modeFor classifies a loaded candidate. The provider decides *whether* to -// synthesize a bootstrap flight — it is the only layer that knows how to count -// its transport's flights — and the gate reads that decision off the manifest. -func modeFor(flight *tapper.Flight) sessionMode { - switch { - case flight == nil: +// modeFor classifies a loaded candidate. A no-flight full-access candidate is +// active even though it has no flight object; an ordinary nil flight is failed +// explicit-selection recovery. +func modeFor(orientation *Orientation) sessionMode { + if orientation == nil || (!orientation.FullAccess && orientation.Flight == nil) { return modeSelect - case flight.Bootstrap: - return modeBootstrap - default: - return modeActive } + return modeActive } -func (g *sessionFlightGate) publish(sessionID string, next *orientationContext) { +func (g *sessionFlightGate) publish(sessionID string, next *orientationContext, notify bool) { state := g.state(sessionID) state.mu.Lock() state.current = next state.mu.Unlock() - g.notifyToolsChanged() + if notify { + g.notifyToolsChanged() + } } func cloneFlight(f *tapper.Flight) *tapper.Flight { @@ -182,6 +327,7 @@ func cloneFlight(f *tapper.Flight) *tapper.Flight { out := *f out.Capabilities = append([]tapper.FlightCapability(nil), f.Capabilities...) out.Cover = append([]tapper.FlightCover(nil), f.Cover...) + out.Subflights = append([]string(nil), f.Subflights...) out.AllowedKegs = append([]string(nil), f.AllowedKegs...) return &out } @@ -191,43 +337,46 @@ func (g *sessionFlightGate) notifyToolsChanged() { return } type markerInput struct{} - sdkmcp.AddTool(g.srv, &sdkmcp.Tool{Name: "_orientation_transition_marker"}, func(context.Context, *sdkmcp.CallToolRequest, markerInput) (*sdkmcp.CallToolResult, any, error) { + sdkmcp.AddTool(g.srv, &sdkmcp.Tool{Name: toolListTransitionMarker}, func(context.Context, *sdkmcp.CallToolRequest, markerInput) (*sdkmcp.CallToolResult, any, error) { return textResult(""), nil, nil }) - g.srv.RemoveTools("_orientation_transition_marker") } -// mode reports the session's authority state. An unseen session is treated as -// modeSelect: nothing has been published for it, so it has no flight and no -// evidence that creating one would help. -func (g *sessionFlightGate) mode(sessionID string) sessionMode { - current := g.current(sessionID) +func toolSurfaceChanged(current, next *orientationContext) bool { + left, right := allowedTools(current), allowedTools(next) + if left == nil || right == nil { + return left != nil || right != nil + } + if len(left) != len(right) { + return true + } + for name := range left { + if !right[name] { + return true + } + } + return false +} + +func currentMode(current *orientationContext) sessionMode { if current == nil { return modeSelect } return current.mode } -// allowedTools returns the allowlist governing sessionID, or nil when every +// allowedTools returns the allowlist governing current, or nil when every // registered tool is available. -func (g *sessionFlightGate) allowedTools(sessionID string) map[string]bool { - switch g.mode(sessionID) { +func allowedTools(current *orientationContext) map[string]bool { + switch currentMode(current) { case modeSelect: return recoveryToolNames - case modeBootstrap: - return bootstrapToolNames default: return nil } } -// lockedError explains why a tool outside the allowlist was refused. The two -// modes need different text: one asks the reader to pick an existing flight, -// the other to create the first one. -func (g *sessionFlightGate) lockedError(sessionID string) error { - if g.mode(sessionID) == modeBootstrap { - return errMCPBootstrapOnly - } +func lockedError(current *orientationContext) error { return errMCPFlightRequired } @@ -253,94 +402,54 @@ func (g *sessionFlightGate) payload(ctx context.Context) string { return current.payload } -func (g *sessionFlightGate) canManage(sessionID string) bool { - current := g.current(sessionID) - return current != nil && current.flight != nil && current.flight.HasCapability(tapper.FlightCapabilityManageFlights) -} - -func (g *sessionFlightGate) canManageKegs(sessionID string) bool { - current := g.current(sessionID) - return current != nil && current.flight != nil && current.flight.HasCapability(tapper.FlightCapabilityManageKegs) +func (g *sessionFlightGate) authorizeMutation(ctx context.Context) error { + return g.authorizeCapability(orientationFromContext(ctx), tapper.FlightCapabilityManageFlights) } -func (g *sessionFlightGate) authorizeMutation(sessionID string) error { - return g.authorizeCapability(sessionID, tapper.FlightCapabilityManageFlights) +func (g *sessionFlightGate) authorizeKegCreation(ctx context.Context) error { + return g.authorizeCapability(orientationFromContext(ctx), tapper.FlightCapabilityManageKegs) } -func (g *sessionFlightGate) authorizeKegCreation(sessionID string) error { - return g.authorizeCapability(sessionID, tapper.FlightCapabilityManageKegs) +func (g *sessionFlightGate) fullAccessReconnect(ctx context.Context) string { + current := orientationFromContext(ctx) + if current != nil && current.fullAccess { + return current.reconnect + } + pinned := g.current(sessionIDFromContext(ctx)) + if pinned != nil && pinned.fullAccess { + return pinned.reconnect + } + return "" } -func (g *sessionFlightGate) authorizeCapability(sessionID string, capability tapper.FlightCapability) error { - current := g.current(sessionID) +func (g *sessionFlightGate) authorizeCapability(current *orientationContext, capability tapper.FlightCapability) error { if current == nil || current.flight == nil { + if current != nil && current.fullAccess { + return nil + } return errMCPFlightRequired } if !current.flight.HasCapability(capability) { - return fmt.Errorf("active flight does not grant %s", capability) + return fmt.Errorf("%w: selected flight does not grant %s", ErrOrientationDenied, capability) } return nil } -func (g *sessionFlightGate) selfTarget(ctx context.Context, target string) (bool, error) { +func (g *sessionFlightGate) orientationTarget(ctx context.Context, target string) (root, active bool, err error) { current := orientationFromContext(ctx) + pinned := g.current(sessionIDFromContext(ctx)) + if (current != nil && current.fullAccess) || (pinned != nil && pinned.fullAccess) { + return false, false, nil + } if current == nil || current.flight == nil { - return false, errMCPFlightRequired + return false, false, errMCPFlightRequired } ref, err := tapper.ParseFlightRef(target, current.flight.Namespace) if err != nil { - return false, err + return false, false, err } - return ref.Canonical() == current.flight.Name, nil -} - -// adoptEditedFlight publishes the exact returned manifest after persistence. -// Flight mutation calls deliberately do not hold calls.RLock, so taking the -// write lock here waits for older in-flight calls without deadlocking itself. -func (g *sessionFlightGate) adoptEditedFlight(ctx context.Context, target string, flight *tapper.Flight) (bool, error) { - self, err := g.selfTarget(ctx, target) - if err != nil || !self { - return self, err - } - g.calls.Lock() - defer g.calls.Unlock() - candidate, renderErr := g.provider.Render(ctx, cloneFlight(flight)) - if renderErr != nil { - warning := "flight update was applied, but orientation refresh failed: " + renderErr.Error() - g.publish(sessionIDFromContext(ctx), &orientationContext{ - payload: errMCPFlightRequired.Error(), warnings: []string{warning}, mode: modeSelect, - }) - return true, errors.New(warning) - } - if candidate == nil { - candidate = &Orientation{} - } - next := &orientationContext{ - flight: cloneFlight(flight), payload: candidate.Payload, - kegs: append([]tapper.OrientationKeg(nil), candidate.Kegs...), - warnings: append([]string(nil), candidate.Warnings...), - mode: modeFor(flight), - } - g.publish(sessionIDFromContext(ctx), next) - return true, nil -} - -func (g *sessionFlightGate) adoptDeletedFlight(ctx context.Context, target string) (bool, error) { - self, err := g.selfTarget(ctx, target) - if err != nil || !self { - return self, err - } - g.calls.Lock() - defer g.calls.Unlock() - // No agent name: this is the self-deletion path, where the flight the - // session was running on has just been removed. The gate has no Tap to ask, - // and "your flight is gone" is the whole message. - payload, payloadErr := tapper.BuildOrientationPayload(nil, "", "", nil, nil) - if payloadErr != nil { - payload = errMCPFlightRequired.Error() - } - g.publish(sessionIDFromContext(ctx), &orientationContext{payload: payload, mode: modeSelect}) - return true, nil + canonical := ref.Canonical() + return current.root != nil && canonical == current.root.Name, canonical == current.flight.Name, nil } func sessionIDFromRequest(req sdkmcp.Request) string { @@ -376,6 +485,15 @@ func orientationFromContext(ctx context.Context) *orientationContext { return current } +func contextWithOrientation(ctx context.Context, current *orientationContext) context.Context { + if current == nil || current.fullAccess || current.root == nil || current.flight == nil || current.revision == "" { + return ctx + } + return keg.WithOrientationState(ctx, keg.OrientationState{ + Root: current.root.Name, Active: current.flight.Name, Revision: current.revision, + }) +} + // SessionFlight returns the immutable flight snapshot captured for the current // MCP tool-call boundary. Hosted discovery tools use it to apply the same // cover as KEG operations without reaching into session storage. @@ -387,6 +505,31 @@ func SessionFlight(ctx context.Context) *tapper.Flight { return cloneFlight(current.flight) } +// SessionFullAccess reports whether the current call runs under no-flight +// identity authority. Such a call has no flight snapshot but is not restricted: +// it reaches everything the identity reaches. Discovery tools must distinguish it +// from the other flightless state — failed-root recovery, which reaches nothing — +// because both report a nil SessionFlight. +func SessionFullAccess(ctx context.Context) bool { + current := orientationFromContext(ctx) + return current != nil && current.fullAccess +} + +// SessionOrientationKegs returns the exact selected-flight projection when +// flight was supplied, or the live no-flight identity / pinned-root graph +// projection when discovery omitted it. Aggregate rows are call-local and +// never cached in session state. +func SessionOrientationKegs(ctx context.Context) []tapper.OrientationKeg { + current := orientationFromContext(ctx) + if current == nil { + return nil + } + if graphDiscovery, _ := ctx.Value(graphDiscoveryContextKey{}).(bool); graphDiscovery { + return append([]tapper.OrientationKeg(nil), current.aggregateKegs...) + } + return append([]tapper.OrientationKeg(nil), current.kegs...) +} + // HasSessionOrientation reports whether the current call is governed by a // session orientation gate. A governed recovery session has no flight but // still returns true; ungated embedded surfaces return false. @@ -395,6 +538,7 @@ func HasSessionOrientation(ctx context.Context) bool { } type orientationContextKey struct{} +type graphDiscoveryContextKey struct{} func (g *sessionFlightGate) middleware(next sdkmcp.MethodHandler) sdkmcp.MethodHandler { return func(ctx context.Context, method string, req sdkmcp.Request) (sdkmcp.Result, error) { @@ -402,17 +546,15 @@ func (g *sessionFlightGate) middleware(next sdkmcp.MethodHandler) sdkmcp.MethodH ctx = context.WithValue(ctx, flightSessionContextKey{}, sessionID) if method == "initialize" { - current, refreshErr := g.refresh(ctx, sessionID) - result, err := next(context.WithValue(ctx, orientationContextKey{}, current), method, req) + current, _ := g.loadAndPin(ctx, sessionID) + callCtx := context.WithValue(ctx, orientationContextKey{}, current) + result, err := next(contextWithOrientation(callCtx, current), method, req) if err != nil { return result, err } if initialized, ok := result.(*sdkmcp.InitializeResult); ok { copyResult := *initialized - copyResult.Instructions = current.payload - if refreshErr != nil { - copyResult.Instructions += "\n\nRecovery warning: " + refreshErr.Error() - } + copyResult.Instructions = initializationInstructions() return ©Result, nil } return result, nil @@ -420,6 +562,7 @@ func (g *sessionFlightGate) middleware(next sdkmcp.MethodHandler) sdkmcp.MethodH current := g.current(sessionID) ctx = context.WithValue(ctx, orientationContextKey{}, current) + ctx = contextWithOrientation(ctx, current) if method == "tools/list" { result, err := next(ctx, method, req) if err != nil { @@ -431,61 +574,211 @@ func (g *sessionFlightGate) middleware(next sdkmcp.MethodHandler) sdkmcp.MethodH } copyResult := *listed copyResult.Tools = make([]*sdkmcp.Tool, 0, len(listed.Tools)) - allowed := g.allowedTools(sessionID) - canManage, canManageKegs := g.canManage(sessionID), g.canManageKegs(sessionID) + allowed := allowedTools(current) for _, tool := range listed.Tools { - if allowed != nil && !allowed[tool.Name] { + if tool.Name == toolListTransitionMarker { continue } - if isFlightMutationTool(tool.Name) && !canManage { + if allowed != nil && !allowed[tool.Name] { continue } - if isKegCreationTool(tool.Name) && !canManageKegs { - continue + copyTool := *tool + if authorityBearingTool(tool.Name) { + copyTool.InputSchema = schemaWithFlight(tool.InputSchema) } - copyResult.Tools = append(copyResult.Tools, tool) + copyResult.Tools = append(copyResult.Tools, ©Tool) } return ©Result, nil } if method == "tools/call" { params, _ := req.GetParams().(*sdkmcp.CallToolParamsRaw) - if params != nil && params.Name == "orient" { + if allowed := allowedTools(current); params != nil && allowed != nil && !allowed[params.Name] { + return errorResult(lockedError(current)), nil + } + if params == nil || !authorityBearingTool(params.Name) { return next(ctx, method, req) } - if allowed := g.allowedTools(sessionID); params != nil && allowed != nil && !allowed[params.Name] { - return errorResult(g.lockedError(sessionID)), nil + if params.Name == "keg_list" { + present, validationErr := toolArgumentPresent(params, "all") + if validationErr != nil { + return errorResult(validationErr), nil + } + if present { + return errorResult(errors.New(`validating "arguments": validating root: unexpected additional properties ["all"]`)), nil + } + } + selected, err := extractFlightArgument(params) + if err != nil { + return orientationFailureResult(fmt.Errorf("%w: %v", ErrOrientationDenied, err)), nil } - if params != nil && isFlightMutationTool(params.Name) { - if err := g.authorizeMutation(sessionID); err != nil { - return errorResult(err), nil + var callOrientation *orientationContext + switch currentMode(current) { + case modeActive: + callOrientation, err = g.resolveCall(ctx, sessionID, selected) + case modeSelect: + if params.Name == "orient" { + if selected != "" { + return errorResult(errors.New("cannot select a flight before this connection has an active pinned root; call `session_refresh`, then `orient`")), nil + } + callOrientation = current + } else { + err = lockedError(current) } - return next(ctx, method, req) } - if params != nil && isKegCreationTool(params.Name) { - if err := g.authorizeKegCreation(sessionID); err != nil { - return errorResult(err), nil + if err != nil { + return orientationFailureResult(err), nil + } + ctx = context.WithValue(ctx, orientationContextKey{}, callOrientation) + ctx = context.WithValue(ctx, graphDiscoveryContextKey{}, params.Name == "keg_list" && selected == "") + ctx = contextWithOrientation(ctx, callOrientation) + if isFlightMutationTool(params.Name) { + if err := g.authorizeMutation(ctx); err != nil { + return orientationFailureResult(err), nil + } + } + if isKegCreationTool(params.Name) { + if err := g.authorizeKegCreation(ctx); err != nil { + return orientationFailureResult(err), nil } - return next(ctx, method, req) } - g.calls.RLock() - defer g.calls.RUnlock() + return next(ctx, method, req) } if method == "resources/read" || method == "resources/subscribe" { if params, ok := req.GetParams().(*sdkmcp.ReadResourceParams); ok && params.URI == orientResourceURI { + if currentMode(current) == modeActive { + resolved, err := g.resolveCall(ctx, sessionID, "") + if err != nil { + return nil, err + } + ctx = context.WithValue(ctx, orientationContextKey{}, resolved) + ctx = contextWithOrientation(ctx, resolved) + } return next(ctx, method, req) } - g.calls.RLock() - defer g.calls.RUnlock() - // Node resources are KEG reads, so bootstrap locks them exactly as - // it locks the KEG tools. - if g.mode(sessionID) != modeActive { - return nil, g.lockedError(sessionID) + if currentMode(current) != modeActive { + return nil, lockedError(current) } + resolved, err := g.resolveCall(ctx, sessionID, "") + if err != nil { + return nil, err + } + ctx = context.WithValue(ctx, orientationContextKey{}, resolved) + ctx = contextWithOrientation(ctx, resolved) } return next(ctx, method, req) } } +func authorityBearingTool(name string) bool { + return name != "" && !ungovernedToolNames[name] +} + +func toolArgumentPresent(params *sdkmcp.CallToolParamsRaw, name string) (bool, error) { + if params == nil || len(params.Arguments) == 0 { + return false, nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(params.Arguments, &object); err != nil { + return false, fmt.Errorf("tool arguments must be an object: %w", err) + } + _, ok := object[name] + return ok, nil +} + +func extractFlightArgument(params *sdkmcp.CallToolParamsRaw) (string, error) { + if params == nil || len(params.Arguments) == 0 { + return "", nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(params.Arguments, &object); err != nil { + return "", fmt.Errorf("tool arguments must be an object: %w", err) + } + raw, ok := object["flight"] + if !ok { + return "", nil + } + var selected string + if err := json.Unmarshal(raw, &selected); err != nil { + return "", errors.New("flight must be a string") + } + delete(object, "flight") + clean, err := json.Marshal(object) + if err != nil { + return "", err + } + params.Arguments = clean + return strings.TrimSpace(selected), nil +} + +func schemaWithFlight(schema any) any { + raw, err := json.Marshal(schema) + if err != nil { + return schema + } + var object map[string]any + if json.Unmarshal(raw, &object) != nil { + return schema + } + properties, _ := object["properties"].(map[string]any) + if properties == nil { + properties = map[string]any{} + object["properties"] = properties + } + properties["flight"] = map[string]any{ + "type": "string", + "description": "optional real flight available to current connection authority; omitted uses the connection-pinned authority", + } + return object +} + +func orientationFailureResult(err error) *sdkmcp.CallToolResult { + if err == nil { + err = ErrOrientationStale + } + code := "ORIENTATION_STALE" + switch { + case errors.Is(err, ErrOrientationRootUnavailable): + code = "ORIENTATION_ROOT_UNAVAILABLE" + case errors.Is(err, ErrOrientationUnavailable): + code = "ORIENTATION_UNAVAILABLE" + case errors.Is(err, ErrOrientationDenied): + code = "ORIENTATION_DENIED" + } + return &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: err.Error()}}, + StructuredContent: map[string]any{ + "code": code, + "reorientRequired": false, + "operationPerformed": false, + }, + IsError: true, + } +} + +func sessionRefreshFailureResult(current *orientationContext, err error) *sdkmcp.CallToolResult { + if err == nil { + err = errors.New("session refresh failed") + } + return &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: "SESSION_REFRESH_FAILED: " + err.Error()}}, + StructuredContent: map[string]any{ + "code": "SESSION_REFRESH_FAILED", + "mode": sessionModeName(currentMode(current)), + "toolsChanged": false, + }, + IsError: true, + } +} + +func sessionModeName(mode sessionMode) string { + switch mode { + case modeActive: + return "active" + default: + return "recovery" + } +} + func isFlightMutationTool(name string) bool { return name == "flight_create" || name == "flight_edit" || name == "flight_delete" } diff --git a/pkg/mcp/session_flight_test.go b/pkg/mcp/session_flight_test.go index 9d7c250e..8767fdd4 100644 --- a/pkg/mcp/session_flight_test.go +++ b/pkg/mcp/session_flight_test.go @@ -2,7 +2,12 @@ package mcp_test import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" "strings" + "sync" "sync/atomic" "testing" "time" @@ -15,7 +20,20 @@ import ( "github.com/jlrickert/tapper/pkg/tapper" ) -func TestMCP_ConfigDrivenOrientationAdoptsFlightWithoutReconnect(t *testing.T) { +// requireConnectionInstructions asserts what initialization is allowed to +// carry: the static KEG operating rules and the directive to orient. It must +// never carry session state — which flight is pinned, its cover, or the +// available KEGs all belong to orient, because only orient is re-callable +// after a context reset. +func requireConnectionInstructions(t *testing.T, instructions string) { + t.Helper() + require.Contains(t, instructions, "# KEG System") + require.Contains(t, instructions, "Call `orient`") + require.NotContains(t, instructions, "## Available KEGs") + require.NotContains(t, instructions, "Active flight:") +} + +func TestMCP_ConfigChangesDoNotReplaceConnectionPinnedRoot(t *testing.T) { ctx, srv, rt := newOrientationServer(t, "") var notifications atomic.Int64 session := connectFlightSession(t, ctx, srv, &sdkmcp.ClientOptions{ @@ -24,24 +42,24 @@ func TestMCP_ConfigDrivenOrientationAdoptsFlightWithoutReconnect(t *testing.T) { }, }) - require.Contains(t, session.InitializeResult().Instructions, "+alpha") - require.Contains(t, session.InitializeResult().Instructions, "Alpha instructions") + requireConnectionInstructions(t, session.InitializeResult().Instructions) writeProjectFlight(t, rt, "beta") before := callCat(t, ctx, session) require.False(t, before.IsError, extractText(t, before), "config changes do not adopt authority before orient") oriented := callOrient(t, ctx, session) - require.Contains(t, oriented, "+beta") - require.Contains(t, oriented, "Beta instructions") - require.NotContains(t, oriented, "Active KEG") - require.True(t, callCat(t, ctx, session).IsError, "calls after orientation use beta authority") + require.Contains(t, oriented, "+alpha") + require.Contains(t, oriented, "Alpha instructions") + require.NotContains(t, oriented, "Beta instructions") + require.False(t, callCat(t, ctx, session).IsError) writeProjectFlight(t, rt, "alpha") oriented = callOrient(t, ctx, session) require.Contains(t, oriented, "+alpha") require.False(t, callCat(t, ctx, session).IsError) - require.Eventually(t, func() bool { return notifications.Load() > 0 }, time.Second, 10*time.Millisecond) + require.Never(t, func() bool { return notifications.Load() > 0 }, 100*time.Millisecond, 10*time.Millisecond, + "read-only orientation must not publish session state or change the tool list") } func TestMCP_StaticFlightIgnoresConfiguredSelectionAndRefreshesManifest(t *testing.T) { @@ -52,7 +70,9 @@ func TestMCP_StaticFlightIgnoresConfiguredSelectionAndRefreshesManifest(t *testi require.Contains(t, callOrient(t, ctx, session), "Alpha instructions") writeFlightCover(t, rt, "alpha", "Alpha refreshed", "other") - require.False(t, callCat(t, ctx, session).IsError, "same-flight changes wait for orientation") + refreshed := callCat(t, ctx, session) + require.True(t, refreshed.IsError) + require.NotContains(t, extractText(t, refreshed), "ORIENTATION_STALE") oriented := callOrient(t, ctx, session) require.Contains(t, oriented, "Alpha refreshed") require.NotContains(t, oriented, "Beta instructions") @@ -64,50 +84,73 @@ func TestMCP_EnvironmentFlightOverridesProjectSelection(t *testing.T) { require.NoError(t, rt.Env().Set("TAP_FLIGHT", "+environment")) session := connectFlightSession(t, ctx, srv, nil) - require.Contains(t, session.InitializeResult().Instructions, "+environment") - require.Contains(t, session.InitializeResult().Instructions, "Environment instructions") + requireConnectionInstructions(t, session.InitializeResult().Instructions) + require.Contains(t, callOrient(t, ctx, session), "+environment") + require.Contains(t, callOrient(t, ctx, session), "Environment instructions") } -func TestMCP_ParallelSessionsAdoptConfigurationIndependently(t *testing.T) { +func TestMCP_ParallelSessionsKeepTheirInitializedRoots(t *testing.T) { ctx, srv, rt := newOrientationServer(t, "") first := connectFlightSession(t, ctx, srv, nil) second := connectFlightSession(t, ctx, srv, nil) writeProjectFlight(t, rt, "beta") - require.Contains(t, callOrient(t, ctx, first), "+beta") - require.True(t, callCat(t, ctx, first).IsError) + require.Contains(t, callOrient(t, ctx, first), "+alpha") + require.False(t, callCat(t, ctx, first).IsError) require.False(t, callCat(t, ctx, second).IsError, "unoriented session retains alpha") - require.Contains(t, callOrient(t, ctx, second), "+beta") - require.True(t, callCat(t, ctx, second).IsError) + require.Contains(t, callOrient(t, ctx, second), "+alpha") + third := connectFlightSession(t, ctx, srv, nil) + requireConnectionInstructions(t, third.InitializeResult().Instructions) + require.Contains(t, callOrient(t, ctx, third), "+beta") + require.True(t, callCat(t, ctx, third).IsError) } -func TestMCP_FailedRefreshRetainsAuthorityAndBlankEntersRecovery(t *testing.T) { +func TestMCP_SelectionChangesNeverMoveInitializedRoot(t *testing.T) { ctx, srv, rt := newOrientationServer(t, "") session := connectFlightSession(t, ctx, srv, nil) writeProjectFlight(t, rt, "missing") res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{}}) require.NoError(t, err) - require.True(t, res.IsError) - require.False(t, callCat(t, ctx, session).IsError, "last valid authority survives failed refresh") + require.False(t, res.IsError) + require.Contains(t, extractText(t, res), "+alpha") + require.False(t, callCat(t, ctx, session).IsError) writeProjectFlight(t, rt, "") - require.Contains(t, callOrient(t, ctx, session), "+baseline", - "an empty project selection falls through to the user baseline") + require.Contains(t, callOrient(t, ctx, session), "+alpha", + "clearing a selection does not replace an initialized root") require.False(t, callCat(t, ctx, session).IsError) writeUserFlight(t, rt, "") - require.Contains(t, callOrient(t, ctx, session), "No KEGs are currently available") - names := listedToolNames(t, ctx, session) - require.ElementsMatch(t, []string{"orient", "list_flights", "flight_show", "auth_info"}, names) + require.Contains(t, callOrient(t, ctx, session), "+alpha") + require.False(t, callCat(t, ctx, session).IsError) } -func TestMCP_OrientRejectsKegInputAndInitializationMatchesOrient(t *testing.T) { +// Initialization now carries the static operating rules so a caller that +// already knows its flight can skip orienting. Orient must keep carrying them +// too: initialization instructions are captured once at connection and are +// discarded by a context reset, so orient is the only route back to them. +func TestMCP_OrientRepeatsTheRulesInitializationAlreadySent(t *testing.T) { + ctx, srv, _ := newOrientationServer(t, "") + session := connectFlightSession(t, ctx, srv, nil) + + requireConnectionInstructions(t, session.InitializeResult().Instructions) + + oriented := callOrient(t, ctx, session) + require.Contains(t, oriented, "# KEG System") + require.Contains(t, oriented, "never read or write node files directly") + require.Contains(t, oriented, "## Available KEGs", "orient adds the session state on top") +} + +func TestMCP_OrientRejectsKegInputAndInitializationOmitsSessionState(t *testing.T) { ctx, srv, _ := newOrientationServer(t, "") session := connectFlightSession(t, ctx, srv, nil) initial := session.InitializeResult().Instructions - require.Equal(t, initial, callOrient(t, ctx, session)) + requireConnectionInstructions(t, initial) + // Initialization shares the static rules with orient but stops there; + // orient adds the flight, its cover, and the KEG listing. + require.NotEqual(t, initial, callOrient(t, ctx, session)) res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "orient", @@ -117,12 +160,144 @@ func TestMCP_OrientRejectsKegInputAndInitializationMatchesOrient(t *testing.T) { require.True(t, res.IsError) } +func TestMCP_RemoteAliasCoverlessRootActivatesFullSurfaceAndCrossFlightKegList(t *testing.T) { + ctx := context.Background() + var catalogRequests atomic.Int64 + root := tapper.HubFlight{ + Namespace: "admin", Slug: "admin", Title: "Admin", Visibility: "private", + Subflights: []string{"+test"}, + } + child := tapper.HubFlight{ + Namespace: "admin", Slug: "test", Title: "Test", Visibility: "private", + Cover: []tapper.HubFlightCover{{Namespace: "admin", Keg: "private", Role: "editor"}}, + } + hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/flights": + _ = json.NewEncoder(w).Encode([]tapper.HubFlight{root, child}) + case "/api/v1/@admin/+admin": + _ = json.NewEncoder(w).Encode(root) + case "/api/v1/@admin/+test": + _ = json.NewEncoder(w).Encode(child) + case "/api/v1/kegs": + catalogRequests.Add(1) + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{ + {Namespace: "admin", Alias: "ecw", Title: "ECW", Summary: "Delivery system", Visibility: "private", Role: "admin"}, + {Namespace: "admin", Alias: "example", Title: "Example", Summary: "Reference material", Visibility: "private", Role: "viewer"}, + {Namespace: "admin", Alias: "private", Title: "Private", Summary: "Covered child keg", Visibility: "private", Role: "editor"}, + }) + case "/api/v1/@admin/kegs/private/settings": + _ = json.NewEncoder(w).Encode(map[string]any{"kegv": "2025-07", "title": "Private", "summary": "Covered child keg"}) + default: + http.NotFound(w, r) + } + })) + defer hub.Close() + + sb := newTestSandbox(t) + require.NoError(t, sb.Setwd("/home/testuser/project")) + rt := sb.Runtime() + config := "flight: \"@admin/+admin\"\n" + + "fallbackHub: tapper-2-jlrickert\n" + + "fallbackNamespace: admin\n" + + "disableAtlasHub: true\n" + + "namespaces:\n admin:\n hub: tapper-2-jlrickert\n" + + "hubs:\n tapper-2-jlrickert:\n kind: remote\n url: " + hub.URL + "\n" + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) + require.NoError(t, err) + require.NoError(t, rt.AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + tap.ConfigService.Reload() + cfg, err := tap.ConfigService.Config() + require.NoError(t, err) + configuredHub, ok := cfg.Hub("tapper-2-jlrickert") + require.True(t, ok, "configured hub missing after writing %s", tap.PathService.UserConfig()) + require.Equal(t, hub.URL, configuredHub.URL) + store, err := tapper.LoadAuthStore(ctx, rt, tap.PathService.AuthStorePath()) + require.NoError(t, err) + store.Set(tapper.CanonicalHubURL(hub.URL), tapper.AuthEntry{AccessToken: "test-token"}) + require.NoError(t, store.Save(ctx, rt, tap.PathService.AuthStorePath())) + tap.AuthValidateFn = func(context.Context, *toolkit.Runtime, string, string) (*tapper.WhoAmI, error) { + return &tapper.WhoAmI{UserID: 1, Username: "admin", DefaultNamespace: "admin", Namespaces: []string{"admin"}}, nil + } + + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{KegTargetOptions: tapper.KegTargetOptions{Flight: "@admin/+admin"}}) + session := connectFlightSession(t, ctx, srv, nil) + requireConnectionInstructions(t, session.InitializeResult().Instructions) + initialOrientation := callOrient(t, ctx, session) + require.Contains(t, initialOrientation, "Active flight: `@admin/+admin`") + require.Contains(t, initialOrientation, "`@admin/+test`") + require.Contains(t, initialOrientation, "`@admin/private`") + require.NotContains(t, initialOrientation, "`@admin/ecw`") + require.NotContains(t, initialOrientation, "`@admin/example`") + + names := listedToolNames(t, ctx, session) + for _, want := range []string{"cat", "create", "edit", "keg_list", "keg_settings", "schema_list", "validate", "flight_create"} { + require.Contains(t, names, want, "coverless active root must publish the complete registered inventory") + } + require.Greater(t, len(names), 40) + + 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)) + rootStructured, err := json.Marshal(rootList.StructuredContent) + require.NoError(t, err) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}]}`, string(rootStructured)) + + explicitRoot, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "@admin/+admin"}}) + require.NoError(t, err) + require.False(t, explicitRoot.IsError, extractText(t, explicitRoot)) + require.Empty(t, extractText(t, explicitRoot)) + + 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)) + selectedStructured, err := json.Marshal(selected.StructuredContent) + require.NoError(t, err) + require.JSONEq(t, `{"kegs":[{"ref":"@admin/private","role":"editor","flights":["@admin/+test"]}]}`, string(selectedStructured)) + require.Equal(t, beforeSelected+1, catalogRequests.Load(), "selected projection discovers the Hub once") + + deniedOperation, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_settings", Arguments: map[string]any{"keg": "@admin/private"}}) + require.NoError(t, err) + require.True(t, deniedOperation.IsError) + allowedOperation, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_settings", Arguments: map[string]any{"flight": "+test", "keg": "@admin/private"}}) + require.NoError(t, err) + require.False(t, allowedOperation.IsError, extractText(t, allowedOperation)) + require.Contains(t, extractText(t, allowedOperation), "title: Private") + + defaultOrient, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{}}) + require.NoError(t, err) + require.Contains(t, extractText(t, defaultOrient), "`@admin/private`") + require.NotContains(t, extractText(t, defaultOrient), "`@admin/ecw`") + + explicitRootOrient, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{"flight": "@admin/+admin"}}) + 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) + require.True(t, invalid.IsError) + require.Contains(t, extractText(t, invalid), "unexpected additional properties") + require.Equal(t, beforeInvalid, catalogRequests.Load(), "removed all must fail schema validation before discovery") +} + func newOrientationServer(t *testing.T, static string) (context.Context, *sdkmcp.Server, *toolkit.Runtime) { t.Helper() ctx := context.Background() sb := newTestSandbox(t) require.NoError(t, sb.Setwd("/home/testuser/project")) rt := sb.Runtime() + installOrientationTestHub(t, rt) writeUserFlight(t, rt, "baseline") writeProjectFlight(t, rt, "alpha") writeFlight(t, rt, "baseline", "Baseline instructions") @@ -130,9 +305,8 @@ func newOrientationServer(t *testing.T, static string) (context.Context, *sdkmcp writeFlight(t, rt, "beta", "Beta instructions") writeFlight(t, rt, "environment", "Environment instructions") - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) - _, err = tap.FlightService.GetFlightFresh(ctx, "+alpha") + tap := newMemoryTap(t, ctx, rt) + _, err := tap.FlightService.GetFlightFresh(ctx, "+alpha") require.NoError(t, err) require.Equal(t, "+alpha", tap.ActiveFlightName("")) srv := mcp.NewServer(tap, "test", mcp.KegDefaults{ @@ -152,7 +326,10 @@ func writeProjectFlight(t *testing.T, rt *toolkit.Runtime, flight string) { func writeUserFlight(t *testing.T, rt *toolkit.Runtime, flight string) { t.Helper() - body := "defaultKeg: personal\nfallbackNamespace: local\nhubs:\n home:\n kind: local\n basePath: ~/kegs\n" + hub := orientationTestHubFor(t, rt) + body := "defaultKeg: personal\nfallbackHub: home\nfallbackNamespace: local\ndisableAtlasHub: true\n" + + "namespaces:\n local:\n hub: home\n" + + "hubs:\n home:\n kind: remote\n url: " + hub.server.URL + "\n tokenEnv: TAPPER_TEST_HUB_TOKEN\n" if flight != "" { body += "flight: +" + flight + "\n" } @@ -170,8 +347,130 @@ func writeFlight(t *testing.T, rt *toolkit.Runtime, slug, instructions string) { func writeFlightCover(t *testing.T, rt *toolkit.Runtime, slug, instructions, kegName string) { t.Helper() - body := "title: " + strings.Title(slug) + "\nvisibility: private\ncover:\n - namespace: local\n keg: " + kegName + "\n role: editor\ninstructions: " + instructions + "\n" - require.NoError(t, rt.AtomicWriteFile("/home/testuser/kegs/flights.d/"+slug+".yaml", []byte(body), 0o644)) + hub := orientationTestHubFor(t, rt) + hub.putFlight(tapper.HubFlight{ + Namespace: "local", Slug: slug, Title: strings.ToUpper(slug[:1]) + slug[1:], + Visibility: tapper.FlightVisibilityPrivate, Instructions: instructions, + Cover: []tapper.HubFlightCover{{Namespace: "local", Keg: kegName, Role: "editor"}}, + }) +} + +type orientationTestHub struct { + mu sync.RWMutex + flights map[string]tapper.HubFlight + kegs map[string]tapper.HubKeg + server *httptest.Server +} + +var orientationTestHubs sync.Map + +func installOrientationTestHub(t *testing.T, rt *toolkit.Runtime) *orientationTestHub { + t.Helper() + hub := &orientationTestHub{ + flights: map[string]tapper.HubFlight{}, + kegs: map[string]tapper.HubKeg{ + "personal": {Namespace: "local", Alias: "personal", Title: "Personal KEG", Summary: "Personal test knowledge", Visibility: "private", Role: "admin"}, + "other": {Namespace: "local", Alias: "other", Title: "Other KEG", Summary: "Other test knowledge", Visibility: "private", Role: "admin"}, + "private": {Namespace: "local", Alias: "private", Title: "Private KEG", Summary: "Private test knowledge", Visibility: "private", Role: "admin"}, + }, + } + hub.server = httptest.NewServer(http.HandlerFunc(hub.serveHTTP)) + orientationTestHubs.Store(rt, hub) + require.NoError(t, rt.Env().Set("TAPPER_TEST_HUB_TOKEN", "test-token")) + t.Cleanup(func() { + orientationTestHubs.Delete(rt) + hub.server.Close() + }) + return hub +} + +func orientationTestHubFor(t *testing.T, rt *toolkit.Runtime) *orientationTestHub { + t.Helper() + value, ok := orientationTestHubs.Load(rt) + require.True(t, ok, "orientation test hub is not installed") + return value.(*orientationTestHub) +} + +func (h *orientationTestHub) putFlight(flight tapper.HubFlight) { + h.mu.Lock() + defer h.mu.Unlock() + if flight.Namespace == "" { + flight.Namespace = "local" + } + if flight.Visibility == "" { + flight.Visibility = tapper.FlightVisibilityPrivate + } + flight.Hash = tapper.FlightManifestHash(tapper.FlightManifest{ + Title: flight.Title, Visibility: flight.Visibility, Capabilities: flight.Capabilities, + Cover: hubFlightCover(flight.Cover), Subflights: flight.Subflights, Instructions: flight.Instructions, + }) + h.flights[flight.Slug] = flight +} + +func hubFlightCover(rows []tapper.HubFlightCover) []tapper.FlightCover { + out := make([]tapper.FlightCover, 0, len(rows)) + for _, row := range rows { + out = append(out, tapper.FlightCover{Namespace: row.Namespace, Keg: row.Keg, Role: tapper.FlightRole(row.Role)}) + } + return out +} + +func (h *orientationTestHub) serveHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + h.mu.Lock() + defer h.mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/flights": + rows := make([]tapper.HubFlight, 0, len(h.flights)) + for _, flight := range h.flights { + rows = append(rows, flight) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Slug < rows[j].Slug }) + _ = json.NewEncoder(w).Encode(rows) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/@local/+"): + slug := strings.TrimPrefix(r.URL.Path, "/api/v1/@local/+") + flight, ok := h.flights[slug] + if !ok { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(flight) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/@local/flights": + var flight tapper.HubFlight + if err := json.NewDecoder(r.Body).Decode(&flight); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + flight.Namespace = "local" + if _, exists := h.flights[flight.Slug]; exists { + http.Error(w, `{"error":"already exists"}`, http.StatusConflict) + return + } + h.flights[flight.Slug] = flight + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(flight) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/kegs": + rows := make([]tapper.HubKeg, 0, len(h.kegs)) + for _, kegRow := range h.kegs { + rows = append(rows, kegRow) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Alias < rows[j].Alias }) + _ = json.NewEncoder(w).Encode(rows) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/@local/kegs": + var payload struct { + Alias string `json:"alias"` + Title string `json:"title"` + Visibility string `json:"visibility"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + h.kegs[payload.Alias] = tapper.HubKeg{Namespace: "local", Alias: payload.Alias, Title: payload.Title, Visibility: payload.Visibility, Role: "admin"} + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } } func connectFlightSession(t *testing.T, ctx context.Context, srv *sdkmcp.Server, opts *sdkmcp.ClientOptions) *sdkmcp.ClientSession { diff --git a/pkg/mcp/session_orientation_context_test.go b/pkg/mcp/session_orientation_context_test.go new file mode 100644 index 00000000..6b5c1794 --- /dev/null +++ b/pkg/mcp/session_orientation_context_test.go @@ -0,0 +1,57 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" +) + +func TestContextWithOrientationNoFlightOmitsGovernanceState(t *testing.T) { + ctx := contextWithOrientation(context.Background(), &orientationContext{ + fullAccess: true, + revision: "identity-revision", + }) + + _, ok := keg.OrientationStateFromContext(ctx) + require.False(t, ok, "no-flight calls must use normal identity ACLs without a governed-flight proof") +} + +func TestContextWithOrientationExplicitFlightCarriesSelfRootedProof(t *testing.T) { + flight := &tapper.Flight{Name: "@team/+restricted", Namespace: "team", Slug: "restricted"} + ctx := contextWithOrientation(context.Background(), &orientationContext{ + root: flight, + flight: flight, + revision: "flight-revision", + }) + + state, ok := keg.OrientationStateFromContext(ctx) + require.True(t, ok) + require.Equal(t, keg.OrientationState{ + Root: "@team/+restricted", + Active: "@team/+restricted", + Revision: "flight-revision", + }, state) +} + +func TestExplicitFlightCallRetainsNoFlightConnectionNudge(t *testing.T) { + gate := newSessionFlightGate(nil) + gate.publish("test-session", &orientationContext{ + fullAccess: true, + reconnect: "start a new connection", + }, false) + flight := &tapper.Flight{Name: "@team/+manager", Namespace: "team", Slug: "manager"} + ctx := context.WithValue(context.Background(), flightSessionContextKey{}, "test-session") + ctx = context.WithValue(ctx, orientationContextKey{}, &orientationContext{ + root: flight, flight: flight, revision: "flight-revision", + }) + + require.Equal(t, "start a new connection", gate.fullAccessReconnect(ctx)) + root, active, err := gate.orientationTarget(ctx, flight.Name) + require.NoError(t, err) + require.False(t, root) + require.False(t, active, "a call-local flight must not be reported as the connection root") +} diff --git a/pkg/mcp/session_transition_test.go b/pkg/mcp/session_transition_test.go index 6a71386d..1b9bb399 100644 --- a/pkg/mcp/session_transition_test.go +++ b/pkg/mcp/session_transition_test.go @@ -4,14 +4,13 @@ import ( "context" "encoding/json" "errors" - "sort" + "fmt" "strings" "sync" "sync/atomic" "testing" "time" - "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/mcp" "github.com/jlrickert/tapper/pkg/tapper" @@ -19,147 +18,280 @@ import ( "github.com/stretchr/testify/require" ) -type fakeSessionBackend struct { - mu sync.Mutex - flights map[string]*tapper.Flight - active string - createdKegs []string - renderErr error - listEnter chan struct{} - listWait chan struct{} +type perCallFlightBackend struct { + mu sync.Mutex + root string + flights map[string]*tapper.Flight + kegs []string + created []string + resolves int + resolveErr error } -func newFakeSessionBackend() *fakeSessionBackend { - active := transitionFlight("active", []tapper.FlightCapability{tapper.FlightCapabilityManageFlights}, "personal", "initial") - other := transitionFlight("other", nil, "other", "other") - return &fakeSessionBackend{ - flights: map[string]*tapper.Flight{active.Name: active, other.Name: other}, - active: active.Name, +type refreshFlightBackend struct { + *perCallFlightBackend + refreshMu sync.Mutex + loadMode string + loadRoot string + loadErr error + loads int + loadStart chan<- struct{} + loadWait <-chan struct{} +} + +func newRefreshFlightBackend(mode string) *refreshFlightBackend { + base := newPerCallFlightBackend() + return &refreshFlightBackend{ + perCallFlightBackend: base, + loadMode: mode, + loadRoot: base.root, } } -func transitionFlight(slug string, capabilities []tapper.FlightCapability, keg, instructions string) *tapper.Flight { - return &tapper.Flight{ - Name: "@local/+" + slug, Namespace: "local", Slug: slug, Source: "test", - FlightManifest: tapper.FlightManifest{ - Title: slug, Visibility: tapper.FlightVisibilityPrivate, - Capabilities: append([]tapper.FlightCapability(nil), capabilities...), - Cover: []tapper.FlightCover{{Namespace: "local", Keg: keg, Role: tapper.FlightRoleEditor}}, - Instructions: instructions, +func (p *refreshFlightBackend) Load(ctx context.Context) (*mcp.Orientation, error) { + p.refreshMu.Lock() + p.loads++ + mode, rootRef, loadErr := p.loadMode, p.loadRoot, p.loadErr + loadStart, loadWait := p.loadStart, p.loadWait + p.loadStart, p.loadWait = nil, nil + p.refreshMu.Unlock() + if loadStart != nil { + close(loadStart) + } + if loadWait != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-loadWait: + } + } + if loadErr != nil { + return nil, loadErr + } + switch mode { + case "active": + return p.Resolve(ctx, rootRef, "") + case "no-flight": + payload, err := tapper.BuildOrientationPayload(nil, "No flight; full access.", "", nil, nil, &tapper.OrientationAuthority{FullAccess: true}) + if err != nil { + return nil, err + } + return &mcp.Orientation{FullAccess: true, Payload: payload, ReconnectInstructions: "start a new session"}, nil + default: + payload, err := tapper.BuildOrientationPayload(nil, "", "", nil, nil, nil) + if err != nil { + return nil, err + } + return &mcp.Orientation{Payload: payload}, nil + } +} + +func (p *refreshFlightBackend) loadCount() int { + p.refreshMu.Lock() + defer p.refreshMu.Unlock() + return p.loads +} + +func (p *refreshFlightBackend) setLoad(mode, root string, err error) { + p.refreshMu.Lock() + defer p.refreshMu.Unlock() + p.loadMode, p.loadErr = mode, err + if root != "" { + p.loadRoot = root + } +} + +func (p *refreshFlightBackend) blockNextLoad(start chan<- struct{}, wait <-chan struct{}) { + p.refreshMu.Lock() + defer p.refreshMu.Unlock() + p.loadStart, p.loadWait = start, wait +} + +func newPerCallFlightBackend() *perCallFlightBackend { + flight := func(slug string, cover []string, capabilities ...tapper.FlightCapability) *tapper.Flight { + f := &tapper.Flight{ + Name: "@team/+" + slug, Namespace: "team", Slug: slug, Source: "test", + FlightManifest: tapper.FlightManifest{ + Title: slug, Visibility: tapper.FlightVisibilityPrivate, + Capabilities: append([]tapper.FlightCapability(nil), capabilities...), + Instructions: "instructions for " + slug, + }, + } + for _, alias := range cover { + f.Cover = append(f.Cover, tapper.FlightCover{Namespace: "team", Keg: alias, Role: tapper.FlightRoleEditor}) + } + return f + } + root := flight("root", []string{"root-keg"}) + child := flight("child", []string{"child-keg"}, tapper.FlightCapabilityManageKegs) + sibling := flight("sibling", []string{"sibling-keg"}) + grandchild := flight("grandchild", []string{"grandchild-keg"}) + root.Subflights = []string{"+child", "+sibling"} + child.Subflights = []string{"+grandchild"} + return &perCallFlightBackend{ + root: root.Name, + flights: map[string]*tapper.Flight{ + root.Name: root, child.Name: child, sibling.Name: sibling, grandchild.Name: grandchild, }, + kegs: []string{"@team/root-keg", "@team/child-keg", "@team/sibling-keg", "@team/grandchild-keg", "@team/new-keg"}, } } -func copyTransitionFlight(in *tapper.Flight) *tapper.Flight { +func clonePerCallFlight(in *tapper.Flight) *tapper.Flight { if in == nil { return nil } out := *in out.Capabilities = append([]tapper.FlightCapability(nil), in.Capabilities...) out.Cover = append([]tapper.FlightCover(nil), in.Cover...) + out.Subflights = append([]string(nil), in.Subflights...) return &out } -func (p *fakeSessionBackend) Load(ctx context.Context) (*mcp.Orientation, error) { - p.mu.Lock() - flight := copyTransitionFlight(p.flights[p.active]) - p.mu.Unlock() - if flight == nil { - payload, err := tapper.BuildOrientationPayload(nil, "", "", nil, nil) - return &mcp.Orientation{Payload: payload}, err - } - return p.Render(ctx, flight) +func (p *perCallFlightBackend) Load(ctx context.Context) (*mcp.Orientation, error) { + return p.Resolve(ctx, p.root, "") } -func (p *fakeSessionBackend) Render(_ context.Context, flight *tapper.Flight) (*mcp.Orientation, error) { +func (p *perCallFlightBackend) Resolve(ctx context.Context, rootRef, selected string) (*mcp.Orientation, error) { p.mu.Lock() - err := p.renderErr + p.resolves++ + root := clonePerCallFlight(p.flights[rootRef]) + flights := make(map[string]*tapper.Flight, len(p.flights)) + for ref, flight := range p.flights { + flights[ref] = clonePerCallFlight(flight) + } + kegs := append([]string(nil), p.kegs...) + resolveErr := p.resolveErr p.mu.Unlock() + if resolveErr != nil { + return nil, fmt.Errorf("%w: %v", mcp.ErrOrientationUnavailable, resolveErr) + } + if strings.TrimSpace(rootRef) == "" && strings.TrimSpace(selected) == "" { + authorizedKegs := make([]tapper.OrientationKeg, 0, len(kegs)) + for _, ref := range kegs { + namespaceAlias := strings.TrimPrefix(ref, "@") + namespace, alias, _ := strings.Cut(namespaceAlias, "/") + authorizedKegs = append(authorizedKegs, tapper.OrientationKeg{ + Ref: ref, Namespace: namespace, Alias: alias, Role: "admin", Source: "test", + }) + } + orientation := &mcp.Orientation{ + Identity: "test-user-1", Kegs: authorizedKegs, AggregateKegs: authorizedKegs, FullAccess: true, + } + if err := mcp.FinalizeOrientation(orientation); err != nil { + return nil, err + } + payload, err := tapper.BuildOrientationPayload(nil, "No flight; full access.", "", authorizedKegs, nil, + &tapper.OrientationAuthority{FullAccess: true, Revision: orientation.Revision}) + if err != nil { + return nil, err + } + orientation.Payload = payload + return orientation, nil + } + if root == nil { + return nil, fmt.Errorf("%w: pinned root %s is unavailable", mcp.ErrOrientationRootUnavailable, rootRef) + } + graph, err := tapper.FlattenFlightGraph(ctx, root, func(_ context.Context, ref string) (*tapper.Flight, error) { + flight := flights[ref] + if flight == nil { + return nil, keg.ErrForbidden + } + return flight, nil + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", mcp.ErrOrientationUnavailable, err) + } + selectedFlight, path, err := graph.Select(selected) if err != nil { + return nil, fmt.Errorf("%w: %v", mcp.ErrOrientationDenied, err) + } + authorizedKegs := make([]tapper.OrientationKeg, 0) + for _, ref := range kegs { + namespaceAlias := strings.TrimPrefix(ref, "@") + namespace, alias, _ := strings.Cut(namespaceAlias, "/") + authorizedKegs = append(authorizedKegs, tapper.OrientationKeg{ + Ref: ref, Namespace: namespace, Alias: alias, Role: "admin", Source: "test", + }) + } + orientationKegs := tapper.ProjectOrientationKegs(selectedFlight, authorizedKegs) + graphFlights := []*tapper.Flight{graph.Root} + graphFlights = append(graphFlights, graph.Available...) + orientation := &mcp.Orientation{ + Root: root, Flight: selectedFlight, Path: path, AvailableFlights: append([]string{root.Name}, graph.AvailableRefs()...), + Identity: "test-user-1", Kegs: orientationKegs, + AggregateKegs: mcp.AggregateOrientationKegs(graphFlights, authorizedKegs), + } + if err := mcp.FinalizeOrientation(orientation); err != nil { return nil, err } - kegs := []tapper.OrientationKeg{{ - Ref: "@local/personal", Namespace: "local", Alias: "personal", Title: "Personal", Role: "admin", Source: "local", FlightCap: "editor", - }, {Ref: "@local/other", Namespace: "local", Alias: "other", Title: "Other", Role: "admin", Source: "local", FlightCap: "editor"}} - payload, err := tapper.BuildOrientationPayload(flight, "", "", kegs, nil) + discovery := orientationKegs + if strings.TrimSpace(selected) == "" { + discovery = orientation.AggregateKegs + } + payload, err := tapper.BuildOrientationPayload(selectedFlight, "", "", discovery, nil, &tapper.OrientationAuthority{ + Root: root, Active: selectedFlight, Path: path, AvailableFlights: orientation.AvailableFlights, + Revision: orientation.Revision, + }) if err != nil { return nil, err } - return &mcp.Orientation{Flight: copyTransitionFlight(flight), Payload: payload, Kegs: kegs}, nil + orientation.Payload = payload + return orientation, nil +} + +func (p *perCallFlightBackend) Render(ctx context.Context, flight *tapper.Flight) (*mcp.Orientation, error) { + if flight == nil { + return nil, errors.New("flight is required") + } + return p.Resolve(ctx, flight.Name, "") } -func (p *fakeSessionBackend) ListFlights(context.Context) ([]string, error) { +func (p *perCallFlightBackend) ListFlights(context.Context) ([]string, error) { p.mu.Lock() defer p.mu.Unlock() - out := make([]string, 0, len(p.flights)) + refs := make([]string, 0, len(p.flights)) for ref := range p.flights { - out = append(out, ref) + refs = append(refs, ref) } - return out, nil + return refs, nil } -func (p *fakeSessionBackend) GetFlight(_ context.Context, ref string) (*tapper.Flight, error) { +func (p *perCallFlightBackend) GetFlight(_ context.Context, ref string) (*tapper.Flight, error) { p.mu.Lock() defer p.mu.Unlock() - parsed, err := tapper.ParseFlightRef(ref, "local") + parsed, err := tapper.ParseFlightRef(ref, "team") if err != nil { return nil, err } flight := p.flights[parsed.Canonical()] if flight == nil { - return nil, errors.New("flight not found") + return nil, keg.ErrNotExist } - return copyTransitionFlight(flight), nil + return clonePerCallFlight(flight), nil } -func (p *fakeSessionBackend) CreateFlight(_ context.Context, opts tapper.CreateFlightOptions) (*tapper.Flight, error) { - p.mu.Lock() - defer p.mu.Unlock() - ref, err := tapper.ParseFlightRef(opts.Ref, "local") +func (p *perCallFlightBackend) CreateFlight(_ context.Context, opts tapper.CreateFlightOptions) (*tapper.Flight, error) { + ref, err := tapper.ParseFlightRef(opts.Ref, "team") if err != nil { return nil, err } - flight := transitionFlight(ref.Slug, opts.Capabilities, "personal", opts.Instructions) - flight.Title, flight.Visibility, flight.Cover = opts.Title, opts.Visibility, append([]tapper.FlightCover(nil), opts.Cover...) + flight := &tapper.Flight{Name: ref.Canonical(), Namespace: ref.Namespace, Slug: ref.Slug, Source: "test", + FlightManifest: tapper.FlightManifest{Title: opts.Title, Visibility: opts.Visibility, Capabilities: opts.Capabilities, Cover: opts.Cover, Subflights: opts.Subflights, Instructions: opts.Instructions}} + p.mu.Lock() p.flights[flight.Name] = flight - return copyTransitionFlight(flight), nil + p.mu.Unlock() + return clonePerCallFlight(flight), nil } -func (p *fakeSessionBackend) UpdateFlight(_ context.Context, opts tapper.UpdateFlightOptions) (*tapper.Flight, error) { - p.mu.Lock() - defer p.mu.Unlock() - ref, err := tapper.ParseFlightRef(opts.Ref, "local") - if err != nil { - return nil, err - } - current := p.flights[ref.Canonical()] - if current == nil { - return nil, errors.New("flight not found") - } - next := copyTransitionFlight(current) - if opts.Title != nil { - next.Title = *opts.Title - } - if opts.Visibility != nil { - next.Visibility = *opts.Visibility - } - if opts.Capabilities != nil { - next.Capabilities = append([]tapper.FlightCapability(nil), (*opts.Capabilities)...) - } - if opts.Instructions != nil { - next.Instructions = *opts.Instructions - } - if opts.Cover != nil { - next.Cover = append([]tapper.FlightCover(nil), (*opts.Cover)...) - } - p.flights[next.Name] = next - return copyTransitionFlight(next), nil +func (p *perCallFlightBackend) UpdateFlight(_ context.Context, opts tapper.UpdateFlightOptions) (*tapper.Flight, error) { + return p.GetFlight(context.Background(), opts.Ref) } -func (p *fakeSessionBackend) DeleteFlight(_ context.Context, opts tapper.DeleteFlightOptions) error { +func (p *perCallFlightBackend) DeleteFlight(_ context.Context, opts tapper.DeleteFlightOptions) error { p.mu.Lock() defer p.mu.Unlock() - ref, err := tapper.ParseFlightRef(opts.Ref, "local") + ref, err := tapper.ParseFlightRef(opts.Ref, "team") if err != nil { return err } @@ -167,248 +299,522 @@ func (p *fakeSessionBackend) DeleteFlight(_ context.Context, opts tapper.DeleteF return nil } -func (p *fakeSessionBackend) ListKegs(context.Context) ([]string, error) { - if p.listEnter != nil { - select { - case p.listEnter <- struct{}{}: - default: - } - <-p.listWait +func (p *perCallFlightBackend) ListKegs(context.Context) ([]string, error) { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.kegs...), nil +} + +func (p *perCallFlightBackend) CreateKeg(_ context.Context, opts tapper.CreateKegOptions) (string, error) { + namespace := opts.Namespace + if namespace == "" { + namespace = "team" } - return []string{"@local/personal", "@local/other"}, nil + ref := "@" + namespace + "/" + opts.Keg + p.mu.Lock() + p.created = append(p.created, ref) + p.mu.Unlock() + return ref, nil } -func (p *fakeSessionBackend) CreateKeg(_ context.Context, opts tapper.CreateKegOptions) (string, error) { +func (p *perCallFlightBackend) SearchKegs(_ context.Context, query string) (mcp.KegSearchResult, error) { p.mu.Lock() defer p.mu.Unlock() - ns := opts.Namespace - if ns == "" { - ns = "local" + 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, Summary: "summary for " + alias, + }) } - ref := "@" + ns + "/" + opts.Keg - p.createdKegs = append(p.createdKegs, ref) - return ref, nil + return mcp.KegSearchResult{Kegs: mcp.SearchIdentityKegs(rows, query)}, nil } -func (p *fakeSessionBackend) Identities(context.Context) ([]mcp.AuthIdentity, error) { - return []mcp.AuthIdentity{{Hub: "test", UserID: 1, Username: "tester", DefaultNamespace: "local", Namespaces: []string{"local"}}}, nil +func (p *perCallFlightBackend) Identities(context.Context) ([]mcp.AuthIdentity, error) { + return []mcp.AuthIdentity{{Hub: "test", UserID: 1, Username: "tester", DefaultNamespace: "team", Namespaces: []string{"team"}}}, nil } -func newTransitionSession(t *testing.T, provider *fakeSessionBackend, opts *sdkmcp.ClientOptions) (*sdkmcp.ClientSession, context.Context) { +func newPerCallFlightSession(t *testing.T, backend *perCallFlightBackend) (*sdkmcp.ClientSession, context.Context) { t.Helper() ctx := context.Background() - sb := newTestSandbox(t) - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) + sandbox := newTestSandbox(t) + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sandbox.Runtime()}) require.NoError(t, err) - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ - OrientationProvider: provider, FlightProvider: provider, KegProvider: provider, IdentityProvider: provider, + server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ + OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, + KegSearchProvider: backend, IdentityProvider: backend, }) - return connectFlightSession(t, ctx, srv, opts), ctx + return connectFlightSession(t, ctx, server, nil), ctx } -func TestMCP_SelfEditImmediatelyAdoptsManifestAndCapabilities(t *testing.T) { - provider := newFakeSessionBackend() - var notifications atomic.Int64 - session, ctx := newTransitionSession(t, provider, &sdkmcp.ClientOptions{ToolListChangedHandler: func(context.Context, *sdkmcp.ToolListChangedRequest) { notifications.Add(1) }}) - require.False(t, callCat(t, ctx, session).IsError) - - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{ - "ref": "+active", "instructions": "updated immediately", "cover": []string{"@local/other=editor"}, - }}) +func newRefreshFlightSession(t *testing.T, backend *refreshFlightBackend, opts *sdkmcp.ClientOptions) (*sdkmcp.ClientSession, context.Context) { + t.Helper() + ctx := context.Background() + sandbox := newTestSandbox(t) + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sandbox.Runtime()}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.Contains(t, extractText(t, res), "updated immediately") - require.True(t, callCat(t, ctx, session).IsError, "cover change must govern the next call") + server := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ + OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, + KegSearchProvider: backend, IdentityProvider: backend, + }) + return connectFlightSession(t, ctx, server, opts), ctx +} - res, err = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{ - "ref": "@local/+active", "capabilities": []string{}, +func callCatKeg(t *testing.T, ctx context.Context, session *sdkmcp.ClientSession, kegRef string) *sdkmcp.CallToolResult { + t.Helper() + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "cat", Arguments: map[string]any{ + "keg": kegRef, "node_ids": []string{"0"}, "content_only": true, }}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.NotContains(t, listedToolNames(t, ctx, session), "flight_edit") - require.Eventually(t, func() bool { return notifications.Load() > 0 }, time.Second, 10*time.Millisecond) + return result } -func TestMCP_SelfDeleteEntersRecoveryAndNotifies(t *testing.T) { - provider := newFakeSessionBackend() - var notifications atomic.Int64 - session, ctx := newTransitionSession(t, provider, &sdkmcp.ClientOptions{ToolListChangedHandler: func(context.Context, *sdkmcp.ToolListChangedRequest) { notifications.Add(1) }}) - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_delete", Arguments: map[string]any{"ref": "+active"}}) +func TestMCP_PerCallRecursiveFlightSelectionIsConcurrentAndIsolated(t *testing.T) { + backend := newPerCallFlightBackend() + session, ctx := newPerCallFlightSession(t, backend) + cases := []struct { + name string + flight string + want string + }{ + {name: "root omitted", want: strings.Join([]string{ + "@team/child-keg\teditor\t@team/+child", + "@team/grandchild-keg\teditor\t@team/+grandchild", + "@team/root-keg\teditor\t@team/+root", + "@team/sibling-keg\teditor\t@team/+sibling", + }, "\n")}, + {name: "root explicit", flight: "@team/+root", want: "@team/root-keg\teditor\t@team/+root"}, + {name: "child", flight: "+child", want: "@team/child-keg\teditor\t@team/+child"}, + {name: "sibling", flight: "+sibling", want: "@team/sibling-keg\teditor\t@team/+sibling"}, + {name: "grandchild", flight: "+grandchild", want: "@team/grandchild-keg\teditor\t@team/+grandchild"}, + } + var wg sync.WaitGroup + for round := 0; round < 5; round++ { + for _, tc := range cases { + tc := tc + wg.Add(1) + go func() { + defer wg.Done() + arguments := map[string]any{} + if tc.flight != "" { + arguments["flight"] = tc.flight + } + result, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: arguments}) + require.NoError(t, err) + require.False(t, result.IsError, extractText(t, result)) + require.Equal(t, tc.want, strings.TrimSpace(extractText(t, result)), tc.name) + }() + } + } + wg.Wait() + + oriented, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "orient", Arguments: map[string]any{"flight": "+grandchild"}}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.ElementsMatch(t, []string{"orient", "list_flights", "flight_show", "auth_info"}, listedToolNames(t, ctx, session)) - require.Eventually(t, func() bool { return notifications.Load() > 0 }, time.Second, 10*time.Millisecond) + require.False(t, oriented.IsError, extractText(t, oriented)) + require.Contains(t, extractText(t, oriented), "Selected flight:") + require.Contains(t, extractText(t, oriented), "@team/+grandchild") + require.Contains(t, extractText(t, oriented), "@team/+root") } -func TestMCP_SelfEditRenderFailureReportsAppliedAndRecovers(t *testing.T) { - provider := newFakeSessionBackend() - provider.renderErr = errors.New("render unavailable") - // Initialization must succeed; arm the failure afterward. - provider.renderErr = nil - session, ctx := newTransitionSession(t, provider, nil) - provider.mu.Lock() - provider.renderErr = errors.New("render unavailable") - provider.mu.Unlock() - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{"ref": "+active", "instructions": "persisted"}}) +func TestMCP_KegListDefaultsToLiveGraphAndExplicitFlightIsExact(t *testing.T) { + backend := newPerCallFlightBackend() + session, ctx := newPerCallFlightSession(t, backend) + + backend.mu.Lock() + beforeInvalid := backend.resolves + backend.mu.Unlock() + invalid, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"all": true}}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.Contains(t, extractText(t, res), "update was applied") - require.ElementsMatch(t, []string{"orient", "list_flights", "flight_show", "auth_info"}, listedToolNames(t, ctx, session)) - stored, err := provider.GetFlight(ctx, "+active") + require.True(t, invalid.IsError) + require.Contains(t, extractText(t, invalid), "unexpected additional properties") + backend.mu.Lock() + require.Equal(t, beforeInvalid, backend.resolves, "invalid selection must fail before live discovery") + backend.mu.Unlock() + + all, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) require.NoError(t, err) - require.Equal(t, "persisted", stored.Instructions) -} + require.False(t, all.IsError, extractText(t, all)) + require.Equal(t, strings.Join([]string{ + "@team/child-keg\teditor\t@team/+child", + "@team/grandchild-keg\teditor\t@team/+grandchild", + "@team/root-keg\teditor\t@team/+root", + "@team/sibling-keg\teditor\t@team/+sibling", + }, "\n"), extractText(t, all)) + require.NotContains(t, extractText(t, all), "new-keg", "identity access outside the root graph must not leak") + + var structured struct { + Kegs []struct { + Ref string `json:"ref"` + Role string `json:"role"` + Flights []string `json:"flights"` + } `json:"kegs"` + } + raw, err := json.Marshal(all.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &structured)) + require.Len(t, structured.Kegs, 4) + require.Equal(t, "@team/child-keg", structured.Kegs[0].Ref) + require.Equal(t, "editor", structured.Kegs[0].Role) + require.Equal(t, []string{"@team/+child"}, structured.Kegs[0].Flights) -func TestMCP_NonSelfMutationKeepsSessionAuthority(t *testing.T) { - provider := newFakeSessionBackend() - session, ctx := newTransitionSession(t, provider, nil) - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{"ref": "+other", "instructions": "changed other"}}) + root, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "@team/+root"}}) + require.NoError(t, err) + require.Equal(t, "@team/root-keg\teditor\t@team/+root", extractText(t, root)) + child, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+child"}}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.False(t, callCat(t, ctx, session).IsError) - require.Contains(t, session.InitializeResult().Instructions, "initial") + require.Equal(t, "@team/child-keg\teditor\t@team/+child", extractText(t, child)) } -func TestMCP_SelfTransitionWaitsForOlderInFlightCall(t *testing.T) { - provider := newFakeSessionBackend() - provider.listEnter, provider.listWait = make(chan struct{}, 1), make(chan struct{}) - session, ctx := newTransitionSession(t, provider, nil) - listDone := make(chan struct{}) - go func() { - _, _ = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) - close(listDone) - }() - <-provider.listEnter - editDone := make(chan struct{}) - go func() { - _, _ = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "flight_edit", Arguments: map[string]any{"ref": "+active", "instructions": "after wait"}}) - close(editDone) - }() - select { - case <-editDone: - t.Fatal("self transition returned before the older call released its authority snapshot") - case <-time.After(50 * time.Millisecond): - } - close(provider.listWait) - select { - case <-listDone: - case <-time.After(time.Second): - t.Fatal("keg_list did not finish") - } - select { - case <-editDone: - case <-time.After(time.Second): - t.Fatal("flight_edit did not finish") +func TestAggregateOrientationKegsUsesHighestEffectiveRole(t *testing.T) { + authorized := []tapper.OrientationKeg{ + {Ref: "@team/shared", Namespace: "team", Alias: "shared", Role: "editor"}, + {Ref: "@team/view-only", Namespace: "team", Alias: "view-only", Role: "viewer"}, } + root := &tapper.Flight{Name: "@team/+root", FlightManifest: tapper.FlightManifest{Cover: []tapper.FlightCover{ + {Namespace: "team", Keg: "shared", Role: tapper.FlightRoleViewer}, + {Namespace: "team", Keg: "view-only", Role: tapper.FlightRoleAdmin}, + }}} + child := &tapper.Flight{Name: "@team/+child", FlightManifest: tapper.FlightManifest{Cover: []tapper.FlightCover{ + {Namespace: "team", Keg: "shared", Role: tapper.FlightRoleAdmin}, + }}} + + rows := mcp.AggregateOrientationKegs([]*tapper.Flight{root, child}, authorized) + require.Len(t, rows, 2) + require.Equal(t, "@team/shared", rows[0].Ref) + require.Equal(t, "editor", mcp.EffectiveOrientationRole(rows[0]), "highest declared cap must still be intersected with identity role") + require.Equal(t, []string{child.Name, root.Name}, rows[0].Flights, "every granting flight must remain visible") + require.Equal(t, "@team/view-only", rows[1].Ref) + require.Equal(t, "viewer", mcp.EffectiveOrientationRole(rows[1])) + require.Equal(t, []string{root.Name}, rows[1].Flights) } -func TestMCP_ProviderInjectionPreservesToolAndResourceContract(t *testing.T) { - local, localCtx := newTestSession(t) - hosted, hostedCtx := newTransitionSession(t, newFakeSessionBackend(), nil) +func TestAggregateOrientationKegsMergesEqualWinningFlightProvenance(t *testing.T) { + authorized := []tapper.OrientationKeg{{ + Ref: "@team/shared", Namespace: "team", Alias: "shared", Role: "editor", + }} + root := &tapper.Flight{Name: "@team/+root", FlightManifest: tapper.FlightManifest{Cover: []tapper.FlightCover{{ + Namespace: "team", Keg: "shared", Role: tapper.FlightRoleEditor, + }}}} + child := &tapper.Flight{Name: "@team/+child", FlightManifest: tapper.FlightManifest{Cover: []tapper.FlightCover{{ + Namespace: "team", Keg: "shared", Role: tapper.FlightRoleAdmin, + }}}} - type toolContract struct { - Name string - InputSchema string - Annotations string - } - tools := func(session *sdkmcp.ClientSession, ctx context.Context) []toolContract { - listed, err := session.ListTools(ctx, nil) - require.NoError(t, err) - out := make([]toolContract, 0, len(listed.Tools)) - for _, tool := range listed.Tools { - input, err := json.Marshal(tool.InputSchema) - require.NoError(t, err) - annotations, err := json.Marshal(tool.Annotations) - require.NoError(t, err) - out = append(out, toolContract{Name: tool.Name, InputSchema: string(input), Annotations: string(annotations)}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out + rows := mcp.AggregateOrientationKegs([]*tapper.Flight{root, child, child}, authorized) + require.Len(t, rows, 1) + require.Equal(t, "editor", mcp.EffectiveOrientationRole(rows[0])) + require.Equal(t, []string{"@team/+child", "@team/+root"}, rows[0].Flights) +} + +func TestMCP_KegSearchIsIdentityScopedLiteralBoundedAndUngoverned(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"}}) + 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") + raw, err := json.Marshal(found.StructuredContent) + require.NoError(t, err) + var structured mcp.KegSearchResult + require.NoError(t, json.Unmarshal(raw, &structured)) + require.Equal(t, []mcp.KegSearchRow{{ + Ref: "@team/new-keg", Role: "admin", Title: "new-keg", + Summary: "summary for new-keg", Visibility: "private", Source: "test", + }}, structured.Kegs) + + empty, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_search", Arguments: map[string]any{"query": " "}}) + 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", Summary: "literal metadata", Role: "viewer", + }) } - require.Equal(t, tools(local, localCtx), tools(hosted, hostedCtx)) + 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) +} - resources := func(session *sdkmcp.ClientSession, ctx context.Context) []string { - listed, err := session.ListResources(ctx, nil) - require.NoError(t, err) - out := make([]string, 0, len(listed.Resources)) - for _, resource := range listed.Resources { - out = append(out, resource.URI+"|"+resource.Name+"|"+resource.MIMEType) - } - sort.Strings(out) - return out +func TestMCP_PerCallSelectionAdoptsGraphAuthorityAndCapabilityChanges(t *testing.T) { + backend := newPerCallFlightBackend() + session, ctx := newPerCallFlightSession(t, backend) + + denied, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{"keg": "denied"}}) + require.NoError(t, err) + require.True(t, denied.IsError) + require.Equal(t, "ORIENTATION_DENIED", denied.StructuredContent.(map[string]any)["code"]) + + created, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_create", Arguments: map[string]any{"flight": "+child", "keg": "allowed"}}) + require.NoError(t, err) + require.False(t, created.IsError, extractText(t, created)) + backend.mu.Lock() + require.Equal(t, []string{"@team/allowed"}, backend.created) + backend.flights["@team/+grandchild"].Cover = []tapper.FlightCover{{Namespace: "team", Keg: "new-keg", Role: tapper.FlightRoleViewer}} + backend.mu.Unlock() + + changed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+grandchild"}}) + require.NoError(t, err) + require.False(t, changed.IsError, extractText(t, changed)) + require.Equal(t, "@team/new-keg\tviewer\t@team/+grandchild", strings.TrimSpace(extractText(t, changed))) + + backend.mu.Lock() + backend.flights["@team/+root"].Subflights = []string{"+sibling"} + backend.mu.Unlock() + delisted, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+grandchild"}}) + require.NoError(t, err) + require.True(t, delisted.IsError) + structured := delisted.StructuredContent.(map[string]any) + require.Equal(t, "ORIENTATION_DENIED", structured["code"]) + require.Equal(t, false, structured["operationPerformed"]) + require.Equal(t, false, structured["reorientRequired"]) +} + +func TestMCP_PerCallDiscoveryAdoptsGraphAdditionDeletionAndTransientRecovery(t *testing.T) { + backend := newPerCallFlightBackend() + session, ctx := newPerCallFlightSession(t, backend) + + backend.mu.Lock() + backend.flights["@team/+added"] = &tapper.Flight{ + Name: "@team/+added", Namespace: "team", Slug: "added", Source: "test", + FlightManifest: tapper.FlightManifest{ + Title: "added", Visibility: tapper.FlightVisibilityPrivate, + Cover: []tapper.FlightCover{{Namespace: "team", Keg: "new-keg", Role: tapper.FlightRoleAdmin}}, + }, } - require.Equal(t, resources(local, localCtx), resources(hosted, hostedCtx)) + backend.flights[backend.root].Subflights = append(backend.flights[backend.root].Subflights, "+added") + backend.mu.Unlock() + + added, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, added.IsError, extractText(t, added)) + require.Contains(t, extractText(t, added), "@team/new-keg\tadmin\t@team/+added", "new descendant must be adopted without orient") + + backend.mu.Lock() + delete(backend.flights, "@team/+child") + backend.mu.Unlock() + deleted, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, deleted.IsError, extractText(t, deleted)) + require.NotContains(t, extractText(t, deleted), "child-keg") + require.NotContains(t, extractText(t, deleted), "grandchild-keg", "a deleted descendant removes its transitive branch") + require.Contains(t, extractText(t, deleted), "@team/new-keg\tadmin\t@team/+added") + denied, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{"flight": "+child"}}) + require.NoError(t, err) + require.True(t, denied.IsError) + require.Equal(t, "ORIENTATION_DENIED", denied.StructuredContent.(map[string]any)["code"]) + + backend.mu.Lock() + backend.resolveErr = errors.New("temporary Hub failure") + backend.mu.Unlock() + transient, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, transient.IsError) + require.Equal(t, "ORIENTATION_UNAVAILABLE", transient.StructuredContent.(map[string]any)["code"]) + backend.mu.Lock() + backend.resolveErr = nil + backend.mu.Unlock() + recovered, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, recovered.IsError, extractText(t, recovered)) + require.Contains(t, extractText(t, recovered), "@team/root-keg\teditor\t@team/+root") + + backend.mu.Lock() + delete(backend.flights, backend.root) + backend.mu.Unlock() + lost, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "keg_list", Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, lost.IsError) + require.Equal(t, "ORIENTATION_ROOT_UNAVAILABLE", lost.StructuredContent.(map[string]any)["code"]) +} - templates := func(session *sdkmcp.ClientSession, ctx context.Context) []string { - listed, err := session.ListResourceTemplates(ctx, nil) +func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *testing.T) { + backend := newPerCallFlightBackend() + session, ctx := newPerCallFlightSession(t, backend) + result, err := session.ListTools(ctx, nil) + require.NoError(t, err) + ungoverned := map[string]bool{"auth_info": true, "keg_search": true, "list_flights": true, "flight_show": true, "session_refresh": true} + seen := map[string]bool{} + for _, tool := range result.Tools { + seen[tool.Name] = true + raw, err := json.Marshal(tool.InputSchema) require.NoError(t, err) - out := make([]string, 0, len(listed.ResourceTemplates)) - for _, resource := range listed.ResourceTemplates { - out = append(out, resource.URITemplate+"|"+resource.Name+"|"+resource.MIMEType) + var schema map[string]any + require.NoError(t, json.Unmarshal(raw, &schema)) + properties, _ := schema["properties"].(map[string]any) + _, hasFlight := properties["flight"] + if ungoverned[tool.Name] { + require.Falsef(t, hasFlight, "%s must remain ungoverned", tool.Name) + } else { + require.Truef(t, hasFlight, "%s must accept optional flight", tool.Name) + required, _ := schema["required"].([]any) + for _, name := range required { + require.NotEqual(t, "flight", name, "%s flight must be optional", tool.Name) + } + } + if tool.Name == "orient" { + require.NotContains(t, properties, "subflight") + } + if tool.Name == "session_refresh" { + require.Empty(t, properties, "session_refresh must remain zero-argument") + } + if tool.Name == "keg_search" { + require.Contains(t, properties, "query") + required, _ := schema["required"].([]any) + require.Contains(t, required, "query") } - sort.Strings(out) - return out + _, hasAll := properties["all"] + require.Falsef(t, hasAll, "%s must not expose removed all selection", tool.Name) } - require.Equal(t, templates(local, localCtx), templates(hosted, hostedCtx)) + require.False(t, seen["repo_init"]) + require.True(t, seen["keg_create"], "management tools stay visible even when the root lacks capability") + require.True(t, seen["flight_create"]) + + rejected, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "session_refresh", Arguments: map[string]any{"flight": "+child"}, + }) + require.NoError(t, err) + require.True(t, rejected.IsError) + require.Contains(t, extractText(t, rejected), "unexpected additional properties") } -func TestMCP_AuthInfoReportsMultipleLocalHubIdentitiesWithoutSecrets(t *testing.T) { - ctx := context.Background() - sb := newTestSandbox(t) - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) - require.NoError(t, err) - store := &tapper.AuthStore{} - store.Set("https://one.example", tapper.AuthEntry{AccessToken: "secret-one", Scope: "admin", RefreshToken: "refresh-one"}) - store.Set("https://two.example", tapper.AuthEntry{AccessToken: "secret-two", Scope: "viewer"}) - require.NoError(t, store.Save(ctx, sb.Runtime(), tap.PathService.AuthStorePath())) - tap.AuthValidateFn = func(_ context.Context, _ *toolkit.Runtime, hubURL, _ string) (*tapper.WhoAmI, error) { - if hubURL == "https://one.example" { - return &tapper.WhoAmI{UserID: 1, Username: "one", DisplayName: "One User", Email: "one@example.test", DefaultNamespace: "one", Namespaces: []string{"team", "one"}}, nil - } - return &tapper.WhoAmI{UserID: 2, Username: "two", DisplayName: "Two User", Email: "two@example.test", DefaultNamespace: "two", Namespaces: []string{"two"}}, nil - } - kegs := newFakeSessionBackend() - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+test"}}, mcp.ServerOptions{KegProvider: kegs}) - session := connectFlightSession(t, ctx, srv, nil) - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "auth_info", Arguments: map[string]any{}}) +func TestMCP_SessionRefreshNoFlightRequiresNewSession(t *testing.T) { + backend := newRefreshFlightBackend("no-flight") + var notifications atomic.Int64 + session, ctx := newRefreshFlightSession(t, backend, &sdkmcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *sdkmcp.ToolListChangedRequest) { + notifications.Add(1) + }, + }) + + requireConnectionInstructions(t, session.InitializeResult().Instructions) + require.Equal(t, 1, backend.loadCount()) + noFlightPayload := orientCall(t, session, ctx, map[string]any{}) + require.Contains(t, noFlightPayload, "No flight was provided") + require.Equal(t, 1, backend.loadCount(), "no-flight orient must not retry activation") + + refreshed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - var structured struct { - Identities []mcp.AuthIdentity `json:"identities"` - Kegs []string `json:"kegs"` - } - raw, err := json.Marshal(res.StructuredContent) + require.False(t, refreshed.IsError, extractText(t, refreshed)) + require.Equal(t, "already_active", refreshed.StructuredContent.(map[string]any)["status"]) + require.Equal(t, false, refreshed.StructuredContent.(map[string]any)["toolsChanged"]) + require.Equal(t, "new_session", refreshed.StructuredContent.(map[string]any)["nextAction"]) + require.Equal(t, int64(0), notifications.Load()) + backend.setLoad("active", "", nil) + unchanged, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) require.NoError(t, err) - require.NoError(t, json.Unmarshal(raw, &structured)) - require.Len(t, structured.Identities, 2) - require.Equal(t, "https://one.example", structured.Identities[0].Hub) - require.Equal(t, []string{"one", "team"}, structured.Identities[0].Namespaces) - require.Equal(t, "https://two.example", structured.Identities[1].Hub) - combined := strings.ToLower(extractText(t, res) + "\n" + string(raw)) - for _, secret := range []string{"secret-one", "secret-two", "refresh-one", "one@example.test", "two@example.test", "scope", "expires", "cookie", "session"} { - require.NotContains(t, combined, secret) - } + require.False(t, unchanged.IsError, extractText(t, unchanged)) + require.Equal(t, "new_session", unchanged.StructuredContent.(map[string]any)["nextAction"]) + require.Equal(t, 1, backend.loadCount(), "no-flight refresh must not consult a newly configured root") + require.Contains(t, listedToolNames(t, ctx, session), "cat") + require.Contains(t, orientCall(t, session, ctx, map[string]any{}), "No flight was provided") } -func TestMCP_DoctorInspectsOnlySelectedKeg(t *testing.T) { - ctx := context.Background() - sb := newTestSandbox(t) - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) - require.NoError(t, err) - require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte("hubs: [invalid\n"), 0o600)) - _, _ = tap.ConfigService.Config() - require.NotEmpty(t, tap.DoctorConfig(), "fixture must contain a local configuration issue") - k := keg.NewLocalKeg(keg.NewMemoryRepo(sb.Runtime()), sb.Runtime()) - require.NoError(t, k.Init(ctx)) - k.SetTarget(&keg.Target{Namespace: "local", KegName: "personal"}) - tap.KegResolver = func(context.Context, tapper.KegTargetOptions, tapper.FlightRole) (keg.Keg, error) { return k, nil } - backend := newFakeSessionBackend() - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ - OrientationProvider: backend, FlightProvider: backend, KegProvider: backend, IdentityProvider: backend, +func TestMCP_ActiveSessionRefreshIsProviderFreeAndKeepsPinnedRoot(t *testing.T) { + backend := newRefreshFlightBackend("active") + session, ctx := newRefreshFlightSession(t, backend, nil) + require.Equal(t, 1, backend.loadCount()) + + backend.setLoad("active", "@team/+sibling", nil) + refreshed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, refreshed.IsError, extractText(t, refreshed)) + structured := refreshed.StructuredContent.(map[string]any) + require.Equal(t, "already_active", structured["status"]) + require.Equal(t, "@team/+root", structured["root"]) + require.Equal(t, false, structured["toolsChanged"]) + require.Equal(t, "orient", structured["nextAction"]) + require.Equal(t, 1, backend.loadCount(), "active refresh must not call the provider") + + payload := orientCall(t, session, ctx, map[string]any{}) + require.Contains(t, payload, "Launch root: `@team/+root`") + require.NotContains(t, payload, "Launch root: `@team/+sibling`") +} + +func TestMCP_FailedSessionRefreshPreservesRecoveryState(t *testing.T) { + backend := newRefreshFlightBackend("selection") + session, ctx := newRefreshFlightSession(t, backend, nil) + beforeTools := listedToolNames(t, ctx, session) + beforeOrient := orientCall(t, session, ctx, map[string]any{}) + + backend.setLoad("active", "", nil) + explicit, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ + Name: "orient", Arguments: map[string]any{"flight": "+root"}, }) - session := connectFlightSession(t, ctx, srv, nil) - res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "doctor", Arguments: map[string]any{"keg": "@local/personal"}}) require.NoError(t, err) - require.False(t, res.IsError, extractText(t, res)) - require.Equal(t, "ok: keg is healthy", extractText(t, res)) + require.True(t, explicit.IsError) + require.Contains(t, extractText(t, explicit), "session_refresh") + resource, err := session.ReadResource(ctx, &sdkmcp.ReadResourceParams{URI: "tapper://orient"}) + require.NoError(t, err) + require.Len(t, resource.Contents, 1) + require.Equal(t, beforeOrient, resource.Contents[0].Text) + require.Equal(t, 1, backend.loadCount(), "recovery orient and resource reads must not retry activation") + + backend.setLoad("no-flight", "", nil) + fallback, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, fallback.IsError) + require.Contains(t, extractText(t, fallback), "cannot fall back to no-flight full access") + require.Equal(t, beforeTools, listedToolNames(t, ctx, session)) + require.Equal(t, beforeOrient, orientCall(t, session, ctx, map[string]any{})) + + backend.setLoad("active", "", errors.New("temporary provider failure")) + failed, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, failed.IsError) + structured := failed.StructuredContent.(map[string]any) + require.Equal(t, "SESSION_REFRESH_FAILED", structured["code"]) + require.Equal(t, "recovery", structured["mode"]) + require.Equal(t, false, structured["toolsChanged"]) + require.Equal(t, beforeTools, listedToolNames(t, ctx, session)) + require.Equal(t, beforeOrient, orientCall(t, session, ctx, map[string]any{})) + + backend.setLoad("active", "", nil) + recovered, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + require.NoError(t, err) + require.False(t, recovered.IsError, extractText(t, recovered)) + require.Equal(t, "activated", recovered.StructuredContent.(map[string]any)["status"]) +} + +func TestMCP_FailedSessionRefreshDoesNotBlockPublishedRecoveryOrientation(t *testing.T) { + backend := newRefreshFlightBackend("selection") + session, ctx := newRefreshFlightSession(t, backend, nil) + before := orientCall(t, session, ctx, map[string]any{}) + + started := make(chan struct{}) + release := make(chan struct{}) + backend.setLoad("active", "", errors.New("temporary provider failure")) + backend.blockNextLoad(started, release) + refreshDone := make(chan *sdkmcp.CallToolResult, 1) + go func() { + result, _ := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "session_refresh", Arguments: map[string]any{}}) + refreshDone <- result + }() + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("session refresh did not reach the provider") + } + require.Equal(t, before, orientCall(t, session, ctx, map[string]any{}), + "published recovery orientation must remain available while refresh is in flight") + close(release) + + select { + case failed := <-refreshDone: + require.NotNil(t, failed) + require.True(t, failed.IsError) + require.Equal(t, "SESSION_REFRESH_FAILED", failed.StructuredContent.(map[string]any)["code"]) + case <-time.After(time.Second): + t.Fatal("session refresh did not finish") + } + require.Equal(t, before, orientCall(t, session, ctx, map[string]any{})) } diff --git a/pkg/mcp/tools_archive.go b/pkg/mcp/tools_archive.go index aced1410..dae373df 100644 --- a/pkg/mcp/tools_archive.go +++ b/pkg/mcp/tools_archive.go @@ -3,7 +3,6 @@ package mcp import ( "context" "fmt" - "strings" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" @@ -12,7 +11,6 @@ import ( func registerArchiveTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { registerExport(srv, tap, defaults) - registerImport(srv, tap, defaults) } // --- export --- @@ -47,38 +45,3 @@ func registerExport(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { return textResult(fmt.Sprintf("exported to %s", path)), nil, nil }) } - -// --- import --- - -type importInput struct { - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` - Path string `json:"path" jsonschema:"path or URL to a keg archive tar.gz file"` -} - -func registerImport(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "import", - Description: "Import nodes from a keg archive tar.gz file", - Annotations: &sdkmcp.ToolAnnotations{ - DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(false), - }, - }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in importInput) (*sdkmcp.CallToolResult, any, error) { - opts := tapper.ImportOptions{ - KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), - Input: in.Path, - } - - imported, err := tap.Import(ctx, opts) - if err != nil { - return errorResult(err), nil, nil - } - - ids := make([]string, len(imported)) - for i, id := range imported { - ids[i] = id.Path() - } - summary := fmt.Sprintf("imported %d node(s): %s", len(imported), strings.Join(ids, ", ")) - return textResult(summary), nil, nil - }) -} diff --git a/pkg/mcp/tools_auth.go b/pkg/mcp/tools_auth.go index 3b19e411..c022c264 100644 --- a/pkg/mcp/tools_auth.go +++ b/pkg/mcp/tools_auth.go @@ -21,7 +21,7 @@ type authInfoOutput struct { func registerAuthInfoTool(srv *sdkmcp.Server, _ KegDefaults, identities IdentityProvider, kegs KegDiscoveryProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "auth_info", - Description: "Report authenticated hub identities and active-flight kegs without exposing credentials or private account data", + 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)}, }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ authInfoInput) (*sdkmcp.CallToolResult, any, error) { found, err := identities.Identities(ctx) diff --git a/pkg/mcp/tools_flight.go b/pkg/mcp/tools_flight.go index 7366e163..34272b81 100644 --- a/pkg/mcp/tools_flight.go +++ b/pkg/mcp/tools_flight.go @@ -20,9 +20,10 @@ type flightCreateInput struct { Ref string `json:"ref" jsonschema:"flight reference (@namespace/+slug; a bare slug uses the default namespace)"` Title string `json:"title,omitempty" jsonschema:"flight title"` Visibility string `json:"visibility,omitempty" jsonschema:"flight visibility: private (default) or public"` - Capabilities []string `json:"capabilities,omitempty" jsonschema:"explicit capabilities; supported: full_access, manage_flights"` + Capabilities []string `json:"capabilities,omitempty" jsonschema:"explicit capabilities; supported: full_access, manage_flights, manage_kegs"` 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)"` + Subflights []string `json:"subflights,omitempty" jsonschema:"ordered child flight references; bare +slug references use this flight's namespace"` } type flightEditInput struct { @@ -32,10 +33,13 @@ type flightEditInput struct { Capabilities []string `json:"capabilities,omitempty" jsonschema:"replacement capabilities; 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 cover entries, e.g. @ns/keg=viewer; omit to keep the current cover"` + 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_show"` } type flightDeleteInput struct { - Ref string `json:"ref" jsonschema:"flight reference (@namespace/+slug; a bare slug uses the default namespace)"` + Ref string `json:"ref" jsonschema:"flight reference (@namespace/+slug; a bare slug uses the default namespace)"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by flight_show"` } // registerFlightTools exposes flight discovery and management over MCP at @@ -60,8 +64,9 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh }) sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "flight_show", - Description: "Show a flight's cover roles and instructions", + Name: "flight_show", + Description: "Show a flight's cover roles and instructions. The result carries the " + + "flight's manifest hash; pass it back as expected_hash when editing this flight.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -71,7 +76,12 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh if err != nil { return errorResult(err), nil, nil } - return textResult(renderFlight(flight)), nil, nil + res := textResult(renderFlight(flight)) + res.StructuredContent = map[string]any{ + "name": flight.Name, + "hash": flight.ManifestHash, + } + return res, nil, nil }) sdkmcp.AddTool(srv, &sdkmcp.Tool{ @@ -93,22 +103,31 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh Capabilities: flightCapabilitiesFromStrings(in.Capabilities), Instructions: in.Instructions, Cover: cover, + Subflights: append([]string(nil), in.Subflights...), }) if err != nil { return errorResult(err), nil, nil } - return textResult(renderFlight(flight)), nil, nil + text := renderFlight(flight) + if nudge := defaults.gate.fullAccessReconnect(ctx); nudge != "" { + text += "\n" + nudge + "\n" + } + return textResult(text), nil, nil }) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_edit", - Description: "Edit a Hub-backed flight; omitted fields keep their current values", + Description: "Call flight_show 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_show) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, OpenWorldHint: boolPtr(true), }, - }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in flightEditInput) (*sdkmcp.CallToolResult, any, error) { - if err := defaults.gate.authorizeMutation(sessionIDFromContext(ctx)); err != nil { + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightEditInput) (*sdkmcp.CallToolResult, any, error) { + if err := defaults.gate.authorizeMutation(ctx); err != nil { + return errorResult(err), nil, nil + } + rootTarget, activeTarget, err := defaults.gate.orientationTarget(ctx, in.Ref) + if err != nil { return errorResult(err), nil, nil } opts := tapper.UpdateFlightOptions{ @@ -116,6 +135,7 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh Title: in.Title, Visibility: in.Visibility, Instructions: in.Instructions, + ExpectedHash: in.ExpectedHash, } if in.Capabilities != nil { capabilities := flightCapabilitiesFromStrings(in.Capabilities) @@ -128,35 +148,46 @@ func registerFlightTools(srv *sdkmcp.Server, defaults KegDefaults, flights Fligh } opts.Cover = &cover } + if in.Subflights != nil { + subflights := append([]string(nil), in.Subflights...) + opts.Subflights = &subflights + } flight, err := flights.UpdateFlight(ctx, opts) if err != nil { return errorResult(err), nil, nil } text := renderFlight(flight) - if _, err := defaults.gate.adoptEditedFlight(ctx, in.Ref, flight); err != nil { - text += "\nRecovery warning: " + err.Error() + "\n" + if rootTarget || activeTarget { + text += "\nThe next authority-bearing call will resolve this live flight graph and authority automatically.\n" } return textResult(text), nil, nil }) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "flight_delete", - Description: "Delete a Hub-backed flight", + Description: "Call flight_show first, then delete a Hub-backed flight using its manifest hash as expected_hash. On conflict, refetch with flight_show and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, OpenWorldHint: boolPtr(true), }, - }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in flightDeleteInput) (*sdkmcp.CallToolResult, any, error) { - if err := defaults.gate.authorizeMutation(sessionIDFromContext(ctx)); err != nil { + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in flightDeleteInput) (*sdkmcp.CallToolResult, any, error) { + if err := defaults.gate.authorizeMutation(ctx); err != nil { + return errorResult(err), nil, nil + } + rootTarget, activeTarget, err := defaults.gate.orientationTarget(ctx, in.Ref) + if err != nil { return errorResult(err), nil, nil } - if err := flights.DeleteFlight(ctx, tapper.DeleteFlightOptions{Ref: in.Ref}); err != nil { + if err := flights.DeleteFlight(ctx, tapper.DeleteFlightOptions{Ref: in.Ref, ExpectedHash: in.ExpectedHash}); err != nil { return errorResult(err), nil, nil } - if _, err := defaults.gate.adoptDeletedFlight(ctx, in.Ref); err != nil { - return textResult("deleted " + in.Ref + "\nRecovery warning: deletion was applied, but the session transition failed: " + err.Error()), nil, nil + text := "deleted " + in.Ref + if rootTarget { + text += "\nORIENTATION_ROOT_UNAVAILABLE: the connection-pinned root was deleted. No replacement root will be adopted; start a new session after the user selects a root." + } else if activeTarget { + text += "\nThe deleted flight is no longer selectable from the pinned root; future calls that request it will be denied." } - return textResult("deleted " + in.Ref), nil, nil + return textResult(text), nil, nil }) } @@ -183,6 +214,12 @@ func renderFlight(f *tapper.Flight) string { } else { b.WriteString("cover: (none; denies all KEG access)\n") } + if len(f.Subflights) > 0 { + b.WriteString("subflights:\n") + for _, ref := range f.Subflights { + fmt.Fprintf(&b, " %s\n", ref) + } + } if f.Instructions != "" { fmt.Fprintf(&b, "\n%s\n", f.Instructions) } diff --git a/pkg/mcp/tools_flight_lock_test.go b/pkg/mcp/tools_flight_lock_test.go index 9d411049..15a808c7 100644 --- a/pkg/mcp/tools_flight_lock_test.go +++ b/pkg/mcp/tools_flight_lock_test.go @@ -40,7 +40,7 @@ func TestMCP_DefaultFlightRestrictsKegs(t *testing.T) { require.Contains(t, blockedText, `keg "@local/private" is not available in flight`) } -func TestMCP_InjectedToolFlightCannotOverrideSessionGate(t *testing.T) { +func TestMCP_OutsideToolFlightCannotOverridePinnedRoot(t *testing.T) { t.Parallel() session, ctx, privateID := newFlightLockedSession(t) @@ -55,7 +55,7 @@ func TestMCP_InjectedToolFlightCannotOverrideSessionGate(t *testing.T) { }) require.NoError(t, err) require.True(t, res.IsError) - require.Contains(t, extractText(t, res), "unexpected additional properties") + require.Contains(t, extractText(t, res), "ORIENTATION_DENIED") covered, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "cat", @@ -95,45 +95,18 @@ func newFlightLockedSession(t *testing.T) (*sdkmcp.ClientSession, context.Contex sb := newTestSandbox(t) require.NoError(t, sb.Setwd("/home/testuser")) rt := sb.Runtime() - sb.MustWriteFile("~/.config/tapper/config.yaml", []byte(`defaultKeg: personal -fallbackNamespace: local -hubs: - home: - kind: local - defaultNamespace: local - basePath: ~/kegs -`), 0o644) - - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) - - _, err = tap.InitKeg(ctx, tapper.InitOptions{Keg: "private", Namespace: "local"}) - require.NoError(t, err) - require.NoError(t, tap.CreateSchema(ctx, tapper.SchemaOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "private"}, - Data: []byte("type: note\n"), - })) - privateID, err := tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "private"}, - Title: "Private", - Attrs: map[string]string{"type": "note"}, + hub := installOrientationTestHub(t, rt) + writeUserFlight(t, rt, "") + tap := newMemoryTap(t, ctx, rt) + privateID := "1" + hub.putFlight(tapper.HubFlight{ + Namespace: "local", Slug: "focused", Title: "Focused", Visibility: tapper.FlightVisibilityPrivate, + Cover: []tapper.HubFlightCover{{Namespace: "local", Keg: "personal", Role: "viewer"}}, + }) + hub.putFlight(tapper.HubFlight{ + Namespace: "local", Slug: "other", Title: "Other", Visibility: tapper.FlightVisibilityPrivate, + Cover: []tapper.HubFlightCover{{Namespace: "local", Keg: "private", Role: "viewer"}}, }) - require.NoError(t, err) - - focused := `title: Focused -cover: - - namespace: local - keg: personal - role: viewer -` - other := `title: Other -cover: - - namespace: local - keg: private - role: viewer -` - require.NoError(t, rt.AtomicWriteFile("/home/testuser/kegs/flights.d/focused.yaml", []byte(focused), 0o644)) - require.NoError(t, rt.AtomicWriteFile("/home/testuser/kegs/flights.d/other.yaml", []byte(other), 0o644)) srv := mcp.NewServer(tap, "test", mcp.KegDefaults{ KegTargetOptions: tapper.KegTargetOptions{Flight: "+focused"}, @@ -157,5 +130,5 @@ cover: session.Close() }) - return session, ctx, privateID.PathNumeric() + return session, ctx, privateID } diff --git a/pkg/mcp/tools_keg.go b/pkg/mcp/tools_keg.go index 537c5989..6f56fa8e 100644 --- a/pkg/mcp/tools_keg.go +++ b/pkg/mcp/tools_keg.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "sort" "strings" @@ -12,6 +13,20 @@ import ( type kegListInput struct{} +type kegListRow struct { + Ref string `json:"ref"` + Role string `json:"role"` + Flights []string `json:"flights"` +} + +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 summary"` +} + type kegCreateInput struct { Keg string `json:"keg" jsonschema:"alias for the new keg (1-64 lowercase letters, digits, or hyphens)"` Namespace string `json:"namespace,omitempty" jsonschema:"target namespace without the @ sigil; empty uses the session default"` @@ -19,31 +34,86 @@ type kegCreateInput struct { Visibility string `json:"visibility,omitempty" jsonschema:"keg visibility: private (default) or public"` } -// registerKegTools exposes identity-authorized discovery filtered through the -// immutable active flight, plus keg creation for flights that carry -// manage_kegs. Transport-specific hub selection is intentionally absent from -// the agent surface. -func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider) { +// registerKegTools exposes identity-authorized discovery, optionally narrowed +// through a call-selected flight snapshot. No-flight sessions may create KEGs; +// real-flight sessions require manage_kegs. Transport-specific hub selection +// is intentionally absent from the agent surface. +func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscoveryProvider, search KegSearchProvider) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_list", - Description: "List identity-authorized kegs covered by the active flight, qualified as @namespace/keg", + Description: "Discover canonical KEGs under current authority. With no flight, every identity-accessible KEG is returned at its real role. Supplying flight selects exactly one available real flight for the call", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(true), }, - }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ kegListInput) (*sdkmcp.CallToolResult, any, error) { + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, in kegListInput) (*sdkmcp.CallToolResult, any, error) { + if HasSessionOrientation(ctx) { + rows := SessionOrientationKegs(ctx) + out := kegListOutput{Kegs: make([]kegListRow, 0, len(rows))} + lines := make([]string, 0, len(rows)) + for _, row := range rows { + effective := EffectiveOrientationRole(row) + flights := append([]string{}, row.Flights...) + out.Kegs = append(out.Kegs, kegListRow{Ref: row.Ref, Role: effective, Flights: flights}) + lines = append(lines, row.Ref+"\t"+effective+"\t"+strings.Join(flights, ",")) + } + res := linesResult(lines) + res.StructuredContent = out + return res, nil, nil + } + // Embedded ungated surfaces retain their identity-only compatibility + // behavior. The agent-safe MCP server always takes the governed path. refs, err := kegs.ListKegs(ctx) if err != nil { return errorResult(err), nil, nil } - return linesResult(filterKegRefs(ctx, refs)), nil, nil + filtered := filterKegRefs(ctx, refs) + out := kegListOutput{Kegs: make([]kegListRow, 0, len(filtered))} + lines := make([]string, 0, len(filtered)) + for _, ref := range filtered { + out.Kegs = append(out.Kegs, kegListRow{Ref: ref, Role: string(tapper.FlightRoleViewer), Flights: []string{}}) + lines = append(lines, ref+"\t"+string(tapper.FlightRoleViewer)+"\t") + } + res := linesResult(lines) + res.StructuredContent = out + return res, nil, nil + }) + + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "keg_search", + Description: "Search identity-accessible KEG metadata across all configured hubs. 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(errors.New("query must not be empty")), 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)) + 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(row.Title), tsvField(row.Summary), 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. Requires the active flight to grant manage_kegs. " + - "The new KEG is not readable until a flight covers it — creating one does not " + - "add it to the active flight's cover.", + Description: "Create a new KEG. No-flight sessions use normal namespace membership; " + + "a selected real flight must grant manage_kegs. Creating a KEG never adds it " + + "to a real flight's cover.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: false, DestructiveHint: boolPtr(false), @@ -53,7 +123,7 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover // The gate refuses this tool before dispatch; the check is repeated here // so an embedded surface without the session gate cannot reach creation // through a flight that never granted it. - if err := defaults.gate.authorizeKegCreation(sessionIDFromContext(ctx)); err != nil { + if err := defaults.gate.authorizeKegCreation(ctx); err != nil { return errorResult(err), nil, nil } ref, err := kegs.CreateKeg(ctx, tapper.CreateKegOptions{ @@ -65,15 +135,31 @@ func registerKegTools(srv *sdkmcp.Server, defaults KegDefaults, kegs KegDiscover if err != nil { return errorResult(err), nil, nil } - return textResult("created keg " + ref + + if nudge := defaults.gate.fullAccessReconnect(ctx); nudge != "" { + return textResult("created keg " + ref + "\n\n" + nudge), nil, nil + } + text := "created keg " + ref + "\n\nIt is not in this flight's cover yet, so KEG tools cannot reach it. " + - "Add it to a flight's cover, then call `orient`."), nil, nil + "Add it to a flight's cover, then call `orient`." + return textResult(text), nil, nil }) } +func tsvField(value string) string { + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\t", " ") + return strings.TrimSpace(value) +} + func filterKegRefs(ctx context.Context, refs []string) []string { flight := SessionFlight(ctx) - if HasSessionOrientation(ctx) && flight == nil { + // Two governed states have no flight snapshot and must not be conflated. + // Failed-root recovery reaches nothing, so it filters to empty. No-flight + // identity authority reaches everything the identity reaches, so it filters + // nothing — otherwise auth_info would report zero KEGs in a session that can + // read them all, contradicting keg_list. + if HasSessionOrientation(ctx) && flight == nil && !SessionFullAccess(ctx) { return []string{} } seen := map[string]struct{}{} diff --git a/pkg/mcp/tools_orient.go b/pkg/mcp/tools_orient.go index 72a00fe7..af0a496e 100644 --- a/pkg/mcp/tools_orient.go +++ b/pkg/mcp/tools_orient.go @@ -8,14 +8,14 @@ import ( "github.com/jlrickert/tapper/pkg/tapper" ) -// orientInput is intentionally empty: orientation adopts flight-scoped -// authority and never selects a KEG. type orientInput struct{} +type sessionRefreshInput struct{} // registerOrientTools wires the orient surface onto srv. Called from // NewServer alongside the other register*Tools helpers. func registerOrientTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { registerOrient(srv, tap, defaults) + registerSessionRefresh(srv, defaults) } func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { @@ -35,21 +35,20 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { "such as a clear or a compact: the connection survives those but the flight " + "instructions do not, and the server cannot detect the reset to re-send them. " + "If you cannot tell whether you have oriented in the current context, you have " + - "not — orient. It is idempotent and also picks up configuration changed since " + - "you connected. While no flight is active the KEG tools are hidden and only " + - "orient, list_flights, flight_show, and auth_info are available; call orient " + - "again once the user selects a flight to unlock them.", + "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. " + + "For a real root, default orient and keg_list discovery summarize its accessible transitive graph and explicit selection is limited to that 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.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in orientInput) (*sdkmcp.CallToolResult, any, error) { if defaults.gate != nil { - current, err := defaults.gate.refresh(ctx, sessionIDFromContext(ctx)) - if err != nil { - return errorResult(err), nil, nil - } - return textResult(current.payload), nil, nil + return textResult(defaults.gate.payload(ctx)), nil, nil } opts := tapper.OrientOptions{KegTargetOptions: resolveKegTarget(ctx, "", defaults)} payload, err := tap.Orient(ctx, opts) @@ -59,3 +58,37 @@ func registerOrient(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { return textResult(payload), nil, nil }) } + +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. " + + "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, + OpenWorldHint: boolPtr(true), + }, + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ sessionRefreshInput) (*sdkmcp.CallToolResult, any, error) { + out, err := defaults.gate.refresh(ctx, sessionIDFromContext(ctx)) + if err != nil { + return sessionRefreshFailureResult(defaults.gate.current(sessionIDFromContext(ctx)), err), nil, nil + } + var message string + switch out.Status { + case "activated": + message = "session activated on " + out.Root + "; call `orient`" + case "already_active": + if out.NextAction == "new_session" { + message = "session already active with no flight; " + defaults.gate.fullAccessReconnect(ctx) + } else { + message = "session already active on " + out.Root + "; call `orient`" + } + default: + message = "the configured flight is still unavailable; repair it, then call `session_refresh` and `orient`" + } + result := textResult(message) + result.StructuredContent = out + return result, nil, nil + }) +} diff --git a/pkg/mcp/tools_orient_test.go b/pkg/mcp/tools_orient_test.go index 33f5ba1b..4f74ff3d 100644 --- a/pkg/mcp/tools_orient_test.go +++ b/pkg/mcp/tools_orient_test.go @@ -32,14 +32,16 @@ func TestMCP_OrientTool_ReturnsSharedKegSystemPayload(t *testing.T) { require.True(t, strings.HasPrefix(text, "# KEG System\n\n"), text) require.Contains(t, text, "Tapper provides an MCP interface for KEG") - require.NotContains(t, text, "CLI") - require.NotContains(t, text, "`tap ") + require.NotContains(t, text, "/api/v1/orient") require.NotContains(t, text, "## Active KEG") require.Contains(t, text, "## Available KEGs") require.NotContains(t, text, "## KEG Instructions") require.Contains(t, text, "Call `keg_settings`") require.Contains(t, text, "## Guidance") require.Contains(t, text, "# Linking conventions") + require.Contains(t, text, "[title](../NODEID)") + require.Contains(t, text, "[title](keg:ALIAS/NODEID)") + require.Contains(t, text, "[title](keg:@NAMESPACE/ALIAS/NODEID)") require.Contains(t, text, "# Snapshot policy") require.NotContains(t, text, "## Host:") require.NotContains(t, strings.ToLower(text), "tier 0") @@ -60,17 +62,17 @@ func TestMCP_OrientToolRejectsKegTarget(t *testing.T) { require.Contains(t, extractText(t, res), "unexpected additional properties") } -func TestMCP_OrientToolRejectsInjectedFlight(t *testing.T) { +func TestMCP_OrientToolRejectsInaccessibleFlight(t *testing.T) { t.Parallel() session, ctx := newTestSession(t) res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "orient", - Arguments: map[string]any{"flight": "f-demo"}, + Arguments: map[string]any{"flight": "+demo"}, }) require.NoError(t, err) require.True(t, res.IsError) - require.Contains(t, extractText(t, res), "unexpected additional properties") + require.Contains(t, extractText(t, res), "ORIENTATION_DENIED") } func TestMCP_Resources_ListSingleOrientResource(t *testing.T) { diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go index e6703672..8d18d24b 100644 --- a/pkg/mcp/tools_read.go +++ b/pkg/mcp/tools_read.go @@ -6,6 +6,7 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -32,10 +33,33 @@ type catInput struct { Query string `json:"query,omitempty" jsonschema:"boolean expression to select nodes (alternative to node_ids)"` } +// nodeReadOutput carries the precondition token alongside each node a read +// returns. Writes require the caller to echo the hash it read, so every read +// that can precede a write has to hand it over; leaving it buried in the +// rendered text would force agents to parse output meant for humans. +type nodeReadOutput struct { + NodeID string `json:"node_id"` + Hash string `json:"hash"` + Content string `json:"content,omitempty"` +} + +func nodeReadOutputs(views []keg.NodeView, withContent bool) []nodeReadOutput { + out := make([]nodeReadOutput, 0, len(views)) + for _, view := range views { + row := nodeReadOutput{NodeID: view.ID.Path(), Hash: view.Hash()} + if withContent { + row.Content = string(view.Content) + } + out = append(out, row) + } + return out +} + func registerCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "cat", - Description: "Read the content of one or more KEG nodes", + Name: "cat", + Description: "Read the content of one or more KEG nodes. Each result carries the " + + "node's hash; pass it back as expected_hash when editing that node.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -49,11 +73,15 @@ func registerCat(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { MetaOnly: in.MetaOnly, StatsOnly: in.StatsOnly, } - result, err := tap.Cat(ctx, opts) + // Read once and render from the same views: calling Cat as well would + // re-read every node and double its access touch. + views, err := tap.CatViews(ctx, opts) if err != nil { return errorResult(err), nil, nil } - return textResult(result), nil, nil + res := textResult(tapper.FormatCatViews(ctx, views, opts)) + res.StructuredContent = map[string]any{"nodes": nodeReadOutputs(views, false)} + return res, nil, nil }) } @@ -264,7 +292,7 @@ type kegSettingsInput struct { func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings", - Description: "Show KEG config (keg file contents). Returns minimal output by default; set minimal=false for full config.", + Description: "Show KEG settings (keg file contents). Returns minimal output by default; set minimal=false for full config.", Annotations: &sdkmcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: boolPtr(false), @@ -300,7 +328,19 @@ func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaul if err != nil { return errorResult(err), nil, nil } - return textResult(result), nil, nil + res := textResult(result) + if !minimal { + // Only the full read returns the stored document verbatim (raw + // file bytes, or cfg.String() for a remote keg) — the same source + // keg_settings_edit replaces. The minimal render is a cross-keg + // summary, so hashing it would hand back a token for something + // nobody can write. + res.StructuredContent = map[string]any{ + "hash": keg.DocumentHash([]byte(result)), + "data": result, + } + } + return res, nil, nil }) } diff --git a/pkg/mcp/tools_repo.go b/pkg/mcp/tools_repo.go index ceeb7905..846eabfc 100644 --- a/pkg/mcp/tools_repo.go +++ b/pkg/mcp/tools_repo.go @@ -10,66 +10,11 @@ import ( "github.com/jlrickert/tapper/pkg/tapper" ) -func registerRepoTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { - registerRepoInit(srv, tap, defaults) +func registerRepoTools(srv *sdkmcp.Server, tap *tapper.Tap) { registerConfig(srv, tap) registerConfigTemplate(srv, tap) } -// --- repo_init --- - -type repoInitInput struct { - Keg string `json:"keg" jsonschema:"keg name for the new repository"` - Namespace string `json:"namespace,omitempty" jsonschema:"namespace the keg belongs to; empty resolves via config. Use 'local' to pin this machine's filesystem hub"` - Hub string `json:"hub,omitempty" jsonschema:"hub override; empty resolves the hub from the namespace"` - User bool `json:"user,omitempty" jsonschema:"pin the reserved @local namespace (filesystem hub)"` - Project bool `json:"project,omitempty" jsonschema:"create under project path"` - Path string `json:"path,omitempty" jsonschema:"explicit filesystem path (implies project destination)"` - Title string `json:"title,omitempty" jsonschema:"keg title"` - Creator string `json:"creator,omitempty" jsonschema:"keg creator identifier"` - NonInteractive bool `json:"non_interactive,omitempty" jsonschema:"skip interactive prompts (always true in MCP context)"` -} - -func registerRepoInit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "repo_init", - Description: "Initialize a new KEG repository", - Annotations: &sdkmcp.ToolAnnotations{ - DestructiveHint: boolPtr(false), - OpenWorldHint: boolPtr(false), - }, - }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in repoInitInput) (*sdkmcp.CallToolResult, any, error) { - opts := tapper.InitOptions{ - Keg: in.Keg, - Namespace: in.Namespace, - Hub: in.Hub, - User: in.User, - Project: in.Project, - Path: in.Path, - Title: in.Title, - Creator: in.Creator, - NonInteractive: true, - // MCP is a full surface: a namespace/hub create requires bootstrap. - RequireBootstrap: true, - } - _ = in.NonInteractive // MCP never prompts; field exists for parity with the CLI flag - - // Destination resolves namespace→hub: a bare name lands in the default - // namespace+hub (typically a remote create); namespace "local" or --user - // pins this machine's filesystem hub. No implicit local default here. - - target, err := tap.InitKeg(ctx, opts) - if err != nil { - return errorResult(err), nil, nil - } - label := tapper.KegBackendLabel(target) - if label == "" { - return textResult(fmt.Sprintf("initialized keg %q", in.Keg)), nil, nil - } - return textResult(fmt.Sprintf("initialized keg %q (%s)", in.Keg, label)), nil, nil - }) -} - // --- config --- type configInput struct { diff --git a/pkg/mcp/tools_resources.go b/pkg/mcp/tools_resources.go index fcb5205f..0dc2c87c 100644 --- a/pkg/mcp/tools_resources.go +++ b/pkg/mcp/tools_resources.go @@ -21,8 +21,8 @@ const ( ) // registerResourceTools wires the MCP Resources surface. The orient resource -// delegates to tap.Orient, so resources/read returns bytes byte-equal to a -// bare orient tool call. +// delegates to the same read-only session view as orient, so resources/read +// returns bytes byte-equal to a bare orient tool call. func registerResourceTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { registerNodeResource(srv, tap, defaults) registerOrientResource(srv, tap, defaults) @@ -88,11 +88,7 @@ func registerOrientResource(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDef }, func(ctx context.Context, req *sdkmcp.ReadResourceRequest) (*sdkmcp.ReadResourceResult, error) { var payload string if defaults.gate != nil { - current, err := defaults.gate.refresh(ctx, sessionIDFromContext(ctx)) - if err != nil { - return nil, err - } - payload = current.payload + payload = defaults.gate.payload(ctx) } else { var err error payload, err = tap.Orient(ctx, tapper.OrientOptions{ diff --git a/pkg/mcp/tools_resources_node_test.go b/pkg/mcp/tools_resources_node_test.go index b1a054e0..aeeaa89f 100644 --- a/pkg/mcp/tools_resources_node_test.go +++ b/pkg/mcp/tools_resources_node_test.go @@ -32,8 +32,7 @@ func TestMCP_NodeResource_SubscribeNotifiesOnChange(t *testing.T) { sb := newTestSandbox(t) rt := sb.Runtime() - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: rt}) - require.NoError(t, err) + tap := newMemoryTap(t, ctx, rt) updates := make(chan string, 8) client := sdkmcp.NewClient(&sdkmcp.Implementation{ @@ -47,7 +46,7 @@ func TestMCP_NodeResource_SubscribeNotifiesOnChange(t *testing.T) { } }, }) - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+test"}}) + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}) serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() done := make(chan error, 1) go func() { diff --git a/pkg/mcp/tools_schema.go b/pkg/mcp/tools_schema.go index c13ce8ea..dee9081d 100644 --- a/pkg/mcp/tools_schema.go +++ b/pkg/mcp/tools_schema.go @@ -65,8 +65,9 @@ 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", + Name: "schema_read", + Description: "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, OpenWorldHint: boolPtr(false), @@ -79,7 +80,13 @@ func registerSchemaRead(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault if err != nil { return errorResult(err), nil, nil } - return textResult(string(data)), nil, nil + res := textResult(string(data)) + res.StructuredContent = map[string]any{ + "type": in.Type, + "hash": keg.DocumentHash(data), + "data": string(data), + } + return res, nil, nil }) } @@ -93,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)", + 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", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -112,15 +119,16 @@ func registerSchemaCreate(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefau // --- schema_edit --- type schemaEditInput struct { - Type string `json:"type" jsonschema:"schema type name to replace"` - Data string `json:"data" jsonschema:"full schema definition as YAML; its declared type must match"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` + Type string `json:"type" jsonschema:"schema type name to replace"` + Data string `json:"data" jsonschema:"full schema definition as YAML; its declared type must match"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by schema_read"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` } func registerSchemaEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_edit", - Description: "Replace an existing keg schema with a new YAML definition", + 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.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), OpenWorldHint: boolPtr(false), @@ -129,6 +137,7 @@ func registerSchemaEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault if err := tap.EditSchema(ctx, tapper.EditSchemaOptions{ KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), Type: in.Type, + ExpectedHash: in.ExpectedHash, Stream: &toolkit.Stream{ IsPiped: true, In: bytes.NewReader([]byte(in.Data)), @@ -143,14 +152,15 @@ func registerSchemaEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault // --- schema_delete --- type schemaDeleteInput struct { - Type string `json:"type" jsonschema:"schema type name to delete"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` + Type string `json:"type" jsonschema:"schema type name to delete"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by schema_read"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` } func registerSchemaDelete(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "schema_delete", - Description: "Delete a keg schema by type name", + 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.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -159,6 +169,7 @@ func registerSchemaDelete(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefau if err := tap.DeleteSchema(ctx, tapper.SchemaOptions{ KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), Type: in.Type, + ExpectedHash: in.ExpectedHash, }); err != nil { return errorResult(err), nil, nil } diff --git a/pkg/mcp/tools_settings_batch_test.go b/pkg/mcp/tools_settings_batch_test.go index 8229e5e4..cf6841db 100644 --- a/pkg/mcp/tools_settings_batch_test.go +++ b/pkg/mcp/tools_settings_batch_test.go @@ -41,7 +41,6 @@ func TestMCP_KegSettingsBatchValidationAndMinimalOutput(t *testing.T) { {"kegs": []string{"personal"}}, {"kegs": []string{"@local/personal", "@local/personal"}}, {"kegs": []string{"@local/personal", "@local/private"}, "minimal": false}, - {"kegs": []string{"@local/private"}}, } for _, args := range cases { res := callKegSettings(t, ctx, session, args) @@ -52,16 +51,19 @@ func TestMCP_KegSettingsBatchValidationAndMinimalOutput(t *testing.T) { func TestMCP_KegSettingsMinimalIncludesInstructions(t *testing.T) { t.Parallel() session, ctx := newTestSession(t) + expectedHash := readSettingsHash(t, session, ctx, "@local/personal") edit, err := session.CallTool(ctx, &sdkmcp.CallToolParams{ Name: "keg_settings_edit", Arguments: map[string]any{ - "keg": "@local/personal", - "data": "kegv: 2025-07\ntitle: Personal KEG\nsummary: Discovery\ninstructions: |\n Targeted guidance.\n", + "keg": "@local/personal", + "expected_hash": expectedHash, + "data": "kegv: 2025-07\ntitle: Personal KEG\nsummary: Discovery\ninstructions: |\n Targeted guidance.\n", }, }) require.NoError(t, err) require.False(t, edit.IsError, extractText(t, edit)) + callOrient(t, ctx, session) single := callKegSettings(t, ctx, session, map[string]any{"keg": "@local/personal"}) require.False(t, single.IsError, extractText(t, single)) diff --git a/pkg/mcp/tools_write.go b/pkg/mcp/tools_write.go index f9c70c52..71cc4adf 100644 --- a/pkg/mcp/tools_write.go +++ b/pkg/mcp/tools_write.go @@ -42,27 +42,29 @@ func registerWriteTools(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefault // --- keg_settings_edit --- type kegSettingsEditInput struct { - Data string `json:"data" jsonschema:"complete validated KEG YAML document"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` + Data string `json:"data" jsonschema:"complete validated KEG YAML document"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by keg_settings"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` } func registerKegSettingsEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "keg_settings_edit", - Description: "Replace the complete KEG configuration with a validated YAML document; requires admin flight authority and editor KEG access", + Description: "Call keg_settings 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) and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in kegSettingsEditInput) (*sdkmcp.CallToolResult, any, error) { - opts := tapper.KegConfigEditOptions{ + opts := tapper.KegSettingsEditOptions{ KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), + ExpectedHash: in.ExpectedHash, Stream: &toolkit.Stream{ IsPiped: true, In: bytes.NewReader([]byte(in.Data)), }, } - if err := tap.KegConfigEdit(ctx, opts); err != nil { + if err := tap.KegSettingsEdit(ctx, opts); err != nil { return errorResult(err), nil, nil } return textResult("KEG settings updated"), nil, nil @@ -139,7 +141,7 @@ type editItemInput struct { NodeID string `json:"node_id"` Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write; required when strict policy and agent mode both block"` Content string `json:"content"` - ExpectedHash string `json:"expected_hash,omitempty"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by cat"` SnapshotBefore bool `json:"snapshot_before,omitempty"` } @@ -160,7 +162,7 @@ func nodeUpdateOutputs(results []keg.NodeUpdateResult) []nodeUpdateOutput { func registerEdit(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "edit", - Description: "Atomically replace the content of 1-100 KEG nodes. Each optional schema selection is required when strict policy and the resolved agent mode both block.", + Description: "Call cat first for every node, then atomically replace the content of 1-100 nodes using each returned hash as that edit's expected_hash. Each optional schema selection is required when strict policy and the resolved agent mode both block. On conflict, merge into the returned current content (or refetch with cat) and retry with the returned current hash.", InputSchema: boundedMutationInputSchema[editInput]("edits"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), @@ -193,14 +195,14 @@ type metaUpdateInput struct { NodeID string `json:"node_id"` Schema string `json:"schema,omitempty" jsonschema:"schema selected for this write; required when strict policy and agent mode both block"` Content string `json:"content"` - ExpectedHash string `json:"expected_hash,omitempty"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by cat"` SnapshotBefore bool `json:"snapshot_before,omitempty"` } func registerMeta(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "meta", - Description: "Read metadata for 1-100 nodes or atomically replace metadata for 1-100 nodes. Each optional schema selection on an update is required when strict policy and the resolved agent mode both block.", + Description: "Read metadata for 1-100 nodes without a token, or call cat first and atomically replace metadata for 1-100 nodes using each returned hash as that update's expected_hash. Each optional schema selection on an update is required when strict policy and the resolved agent mode both block. On conflict, merge into the returned current metadata (or refetch with cat) and retry with the returned current hash.", InputSchema: boundedMutationInputSchema[metaInput]("node_ids", "updates"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(false), @@ -234,43 +236,57 @@ func registerMeta(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { // --- remove --- type removeInput struct { - NodeIDs []string `json:"node_ids" jsonschema:"node IDs to remove"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` + Nodes []removeNodeInput `json:"nodes" jsonschema:"1-100 nodes to remove atomically"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` +} + +type removeNodeInput struct { + NodeID string `json:"node_id"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by cat"` } func registerRemove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "remove", - Description: "Remove one or more KEG nodes", + Description: "Call cat first for every node, then atomically remove 1-100 nodes using each returned hash as that node's expected_hash. On conflict, refetch with cat and retry with the returned current hash.", + InputSchema: boundedMutationInputSchema[removeInput]("nodes"), Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), }, }, func(ctx context.Context, req *sdkmcp.CallToolRequest, in removeInput) (*sdkmcp.CallToolResult, any, error) { + nodeIDs := make([]string, len(in.Nodes)) + expectedHashes := make(map[string]string, len(in.Nodes)) + for i, node := range in.Nodes { + nodeIDs[i] = node.NodeID + expectedHashes[node.NodeID] = node.ExpectedHash + } opts := tapper.RemoveOptions{ KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), - NodeIDs: in.NodeIDs, + NodeIDs: nodeIDs, + ExpectedHashes: expectedHashes, } if err := tap.Remove(ctx, opts); err != nil { return errorResult(err), nil, nil } - return textResult(fmt.Sprintf("removed %d node(s)", len(in.NodeIDs))), nil, nil + return textResult(fmt.Sprintf("removed %d node(s)", len(in.Nodes))), nil, nil }) } // --- move --- type moveInput struct { - SourceID string `json:"source_id" jsonschema:"source node ID"` - DestID string `json:"dest_id" jsonschema:"destination node ID"` - Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` + SourceID string `json:"source_id" jsonschema:"source node ID"` + DestID string `json:"dest_id" jsonschema:"destination node ID"` + ExpectedHash string `json:"expected_hash" jsonschema:"precondition token returned by cat"` + Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"` } func registerMove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "move", - Description: "Move (rename) a KEG node to a new ID", + Description: "Call cat first, then move (rename) a KEG node to a new ID using the returned hash as expected_hash. On conflict, refetch with cat and retry with the returned current hash.", Annotations: &sdkmcp.ToolAnnotations{ DestructiveHint: boolPtr(true), OpenWorldHint: boolPtr(false), @@ -280,6 +296,7 @@ func registerMove(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) { KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults), SourceID: in.SourceID, DestID: in.DestID, + ExpectedHash: in.ExpectedHash, } if err := tap.Move(ctx, opts); err != nil { diff --git a/pkg/parity/data/testuser/.config/tapper/config.yaml b/pkg/parity/data/testuser/.config/tapper/config.yaml index 8652d44f..f8c06898 100644 --- a/pkg/parity/data/testuser/.config/tapper/config.yaml +++ b/pkg/parity/data/testuser/.config/tapper/config.yaml @@ -1,6 +1,11 @@ defaultKeg: personal fallbackNamespace: local +fallbackHub: home hubs: home: - kind: local - basePath: ~/kegs + kind: remote + url: https://fixture.invalid + token: test-token +namespaces: + local: + hub: home diff --git a/pkg/parity/data/testuser/kegs/flights.d/parity.yaml b/pkg/parity/data/testuser/kegs/flights.d/parity.yaml deleted file mode 100644 index 75c12d0e..00000000 --- a/pkg/parity/data/testuser/kegs/flights.d/parity.yaml +++ /dev/null @@ -1,9 +0,0 @@ -title: Parity test flight -visibility: private -capabilities: - - manage_flights -cover: - - namespace: local - keg: personal - role: editor -instructions: Test-only flight covering the parity fixture. diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index 70cb613e..1d2f39e0 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -1,18 +1,13 @@ package parity_test import ( - "context" - "errors" "reflect" "testing" - "github.com/jlrickert/cli-toolkit/sandbox" - sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/jlrickert/tapper/pkg/cli" - "github.com/jlrickert/tapper/pkg/mcp" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -27,19 +22,19 @@ var tapMethodToSurfaces = map[string]struct { MCP string // MCP tool name (e.g., "list", "repo_init", "index") }{ // Read operations - "Cat": {CLI: "cat", MCP: "cat"}, - "List": {CLI: "list", MCP: "list"}, - "Grep": {CLI: "grep", MCP: "grep"}, - "Tags": {CLI: "tags", MCP: "tags"}, - "Backlinks": {CLI: "backlinks", MCP: "backlinks"}, - "Links": {CLI: "links", MCP: "links"}, - "Info": {CLI: "info", MCP: "info"}, - "KegSettings": {CLI: "keg settings", MCP: "keg_settings"}, - "KegConfigEdit": {CLI: "keg settings edit", MCP: "keg_settings_edit"}, - "Stats": {CLI: "stats", MCP: "stats"}, - "ListIndexes": {CLI: "index list", MCP: "list_indexes"}, - "IndexCat": {CLI: "index get", MCP: "index_cat"}, - "Doctor": {CLI: "doctor", MCP: "doctor"}, + "Cat": {CLI: "cat", MCP: "cat"}, + "List": {CLI: "list", MCP: "list"}, + "Grep": {CLI: "grep", MCP: "grep"}, + "Tags": {CLI: "tags", MCP: "tags"}, + "Backlinks": {CLI: "backlinks", MCP: "backlinks"}, + "Links": {CLI: "links", MCP: "links"}, + "Info": {CLI: "info", MCP: "info"}, + "KegSettings": {CLI: "keg settings", MCP: "keg_settings"}, + "KegSettingsEdit": {CLI: "keg settings edit", MCP: "keg_settings_edit"}, + "Stats": {CLI: "stats", MCP: "stats"}, + "ListIndexes": {CLI: "index list", MCP: "list_indexes"}, + "IndexCat": {CLI: "index get", MCP: "index_cat"}, + "Doctor": {CLI: "doctor", MCP: "doctor"}, // Write operations "Create": {CLI: "create", MCP: "create"}, @@ -113,11 +108,16 @@ var tapMethodsExcluded = map[string]string{ "EditBatch": "MCP batch backing operation; CLI edit remains a one-node command", "MetaBatch": "MCP batch backing operation; CLI meta remains a one-node command", "NodeSnapshotBatch": "MCP batch backing operation; CLI snapshot create remains a one-node command", + "CatViews": "structured accessor behind Cat; MCP cat 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", + "SchemaHash": "explicit CLI read-before-write helper; MCP schema_read returns the same token", + "KegSettingsHash": "explicit CLI read-before-write helper; MCP keg_settings returns the same token", "ConfigEdit": "interactive editor; not exposed via MCP", "AuthRefreshAll": "startup credential renewal invoked by the CLI root command (covers `tap` and `tap mcp`); not a user-facing operation", "UpdateFlight": "underlying partial-update operation used by MCP flight_edit; CLI users use `flight edit`", "LookupKeg": "internal resolution helper; not a user-facing operation", "ResolveNodeRef": "internal node-reference resolver shared by surfaces; not a user-facing operation", + "OrientationKegs": "internal MCP authority helper; keg_list is the governed user-facing discovery surface", "WatchNode": "streaming, not request/response: CLI surface is `tap watch` (long-lived stream); " + "MCP surface is the resources/subscribe protocol capability (not a tool), wired via " + "SubscribeHandler in pkg/mcp/server.go. Payload parity is impossible — MCP notifications " + @@ -145,13 +145,14 @@ var tapMethodsExcluded = map[string]string{ "SetBootstrapNamespace": "CLI-only bootstrap step; adopts the hub's default namespace after login, not an MCP operation", "SetHubDefaultNamespaceByURL": "CLI-only auth/bootstrap helper; adopts the hub's default namespace after login, " + "not a standalone user-facing operation", - "SetFallbackKeg": "CLI-only bootstrap step; persists the chosen keg as the user-level fallback after login, not an MCP operation", - "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`", - "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", - "ActiveAgentName": "internal pure read of the `tap launch` agent driving the process; reported in orientation and telemetry rather than being an operation of its own", - "OrientationForFlight": "internal session-orientation builder used by initialize, orient, and the orient resource", + "SetFallbackKeg": "CLI-only bootstrap step; persists the chosen keg as the user-level fallback after login, not an MCP operation", + "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`", + "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", + "ActiveAgentName": "internal pure read of the `tap launch` agent driving the process; reported in orientation and telemetry 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", // 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. @@ -164,7 +165,6 @@ var tapMethodsExcluded = map[string]string{ "KegVisibility": "UI-only visibility management; MCP must not flip a keg between public and private", "NamespaceList": "namespace discovery folded into auth_info's identity payload; the standalone tool was tenant-administration shaped", "License": "prints bundled license text; CLI-only via `tap version --license`", - "Graph": "deprecated and disabled on MCP: renders a standalone HTML page an agent cannot display, and returning ~8KB of markup as tool text is pure context cost; `tap graph --output` remains until the feature is removed", // Experimental launcher. Starting a process on the operator's machine is // not an agent operation and must not become an MCP tool. "Launch": "CLI-only: starts an agent harness as a local subprocess; MCP must never spawn processes on its host", @@ -227,43 +227,8 @@ func TestCoverage_AllTapMethodsHaveBothSurfaces(t *testing.T) { // collectMCPToolNames returns the set of registered MCP tool names. func collectMCPToolNames(t *testing.T) map[string]bool { t.Helper() - - sb := sandbox.NewSandbox(t, &sandbox.Options{ - Data: testdata, - Home: "/home/testuser", - User: "testuser", - }, sandbox.WithFixture("testuser", "~")) - ctx := sb.Context() - - tap, err := tapper.NewTap(tapper.TapOptions{ - Runtime: sb.Runtime(), - }) - require.NoError(t, err) - - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{ - KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+parity"}, - }, mcp.ServerOptions{SharedFilesystem: true}) - serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() - - done := make(chan error, 1) - go func() { - done <- srv.Run(ctx, serverTransport) - }() - t.Cleanup(func() { - if err := <-done; err != nil && !errors.Is(err, context.Canceled) { - t.Errorf("MCP server error: %v", err) - } - }) - - client := sdkmcp.NewClient(&sdkmcp.Implementation{ - Name: "coverage-test", - Version: "0.1", - }, nil) - session, err := client.Connect(ctx, clientTransport, nil) - require.NoError(t, err) - t.Cleanup(func() { session.Close() }) - - res, err := session.ListTools(ctx, nil) + env := newParityEnv(t) + res, err := env.session.ListTools(env.ctx, nil) require.NoError(t, err) tools := make(map[string]bool) diff --git a/pkg/parity/parity_read_test.go b/pkg/parity/parity_read_test.go index 9a0ca353..1cb4f4fb 100644 --- a/pkg/parity/parity_read_test.go +++ b/pkg/parity/parity_read_test.go @@ -90,23 +90,6 @@ func TestParity_ReadOperations(t *testing.T) { }, WantErr: true, }, - // --- cat with a keg:/ ref (Tap.Cat via resolveNodeArg) --- - // - // The fixture registers the current keg under the "personal" alias, so - // "keg:personal/0" resolves that alias through the tap-config kegs map - // back to the same keg. Both surfaces must route the prefixed ref through - // the shared Tap.resolveNodeArg choke point and read node 0's content, - // identical to passing a bare "0". - { - Name: "cat/alias_ref_resolves_same_keg", - CLIArgs: []string{"cat", "keg:personal/0", "--content-only"}, - MCPTool: "cat", - MCPInput: map[string]any{ - "node_ids": []string{"keg:personal/0"}, - "content_only": true, - }, - }, - // --- list (Tap.List) --- // CLI defaults to --limit 0 (unlimited). MCP defaults to 50 when limit // is omitted (0); passing -1 requests unlimited. Both surfaces use diff --git a/pkg/parity/parity_test.go b/pkg/parity/parity_test.go index 783dc6e3..524dd10b 100644 --- a/pkg/parity/parity_test.go +++ b/pkg/parity/parity_test.go @@ -13,6 +13,8 @@ import ( "context" "embed" "errors" + "fmt" + "path/filepath" "strings" "testing" @@ -21,7 +23,9 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" + "github.com/jlrickert/tapper/internal/testkegrepo" "github.com/jlrickert/tapper/pkg/cli" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/mcp" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -56,17 +60,32 @@ func newParityEnv(t *testing.T) *parityEnv { rt := sb.Runtime() ctx := sb.Context() + sharedKeg := newParityKeg(t, ctx, rt) tap, err := tapper.NewTap(tapper.TapOptions{ Runtime: rt, }) require.NoError(t, err) + tap.KegResolver = func(_ context.Context, opts tapper.KegTargetOptions, _ tapper.FlightRole) (keg.Keg, error) { + ref := strings.TrimSpace(opts.Keg) + if ref == "" { + ref = "personal" + } + if strings.HasPrefix(ref, "@") { + _, ref, _ = strings.Cut(strings.TrimPrefix(ref, "@"), "/") + } + if ref != "personal" { + return nil, fmt.Errorf("keg %q: %w", ref, keg.ErrNotExist) + } + return sharedKeg, nil + } - // Set up MCP server with in-memory transport. Parity is measured against - // the CLI's own MCP peer — `tap mcp` — so this is the shared-filesystem - // surface, local attachment paths included. - srv := mcp.NewServer(tap, "test", mcp.KegDefaults{ - KegTargetOptions: tapper.KegTargetOptions{Flight: "@local/+parity"}, - }, mcp.ServerOptions{SharedFilesystem: true}) + // Both surfaces use the same remote-targeted LocalKeg orchestration over a + // concurrency-safe internal repository. No production resolver can select + // this repository; it exists only in tests. + srv := mcp.NewServer(tap, "test", mcp.KegDefaults{}, mcp.ServerOptions{ + SharedFilesystem: true, + OrientationProvider: parityOrientationProvider{}, + }) serverTransport, clientTransport := sdkmcp.NewInMemoryTransports() done := make(chan error, 1) @@ -98,10 +117,89 @@ func newParityEnv(t *testing.T) *parityEnv { } } +type parityOrientationProvider struct{} + +func (parityOrientationProvider) Load(context.Context) (*mcp.Orientation, error) { + return newParityOrientation() +} + +func (parityOrientationProvider) Render(context.Context, *tapper.Flight) (*mcp.Orientation, error) { + return newParityOrientation() +} + +func (parityOrientationProvider) Resolve(context.Context, string, string) (*mcp.Orientation, error) { + return newParityOrientation() +} + +func newParityOrientation() (*mcp.Orientation, error) { + kegs := []tapper.OrientationKeg{{ + Ref: "@local/personal", Namespace: "local", Alias: "personal", + Title: "Personal KEG", Role: "admin", Source: "test", FlightCap: "admin", + }} + orientation := &mcp.Orientation{ + Identity: "parity-test", Kegs: kegs, AggregateKegs: kegs, FullAccess: true, + ReconnectInstructions: "start a new parity test session", + } + if err := mcp.FinalizeOrientation(orientation); err != nil { + return nil, err + } + payload, err := tapper.BuildOrientationPayload(nil, "Parity test authority.", "", kegs, nil, + &tapper.OrientationAuthority{FullAccess: true, Revision: orientation.Revision}) + if err != nil { + return nil, err + } + orientation.Payload = payload + return orientation, nil +} + +func newParityKeg(t *testing.T, ctx context.Context, rt *toolkit.Runtime) keg.Keg { + t.Helper() + repo := testkegrepo.NewMemoryRepository(rt) + base := "/home/testuser/kegs/@local/personal" + settings, err := rt.ReadFile(filepath.Join(base, "keg")) + require.NoError(t, err) + require.NoError(t, repo.WriteSettingsDocument(ctx, settings)) + for _, rawID := range []string{"0", "1"} { + id, err := keg.ParseNode(rawID) + require.NoError(t, err) + require.NotNil(t, id) + nodeDir := filepath.Join(base, rawID) + content, err := rt.ReadFile(filepath.Join(nodeDir, keg.MarkdownContentFilename)) + require.NoError(t, err) + require.NoError(t, repo.WriteContent(ctx, *id, content)) + meta, err := rt.ReadFile(filepath.Join(nodeDir, "meta.yaml")) + require.NoError(t, err) + require.NoError(t, repo.WriteMeta(ctx, *id, meta)) + rawStats, err := rt.ReadFile(filepath.Join(nodeDir, "stats.json")) + require.NoError(t, err) + stats, err := keg.ParseStats(ctx, rawStats) + require.NoError(t, err) + require.NoError(t, repo.WriteStats(ctx, *id, stats)) + } + dexDir := filepath.Join(base, "dex") + entries, err := rt.ReadDir(dexDir) + require.NoError(t, err) + for _, entry := range entries { + if entry.IsDir() { + continue + } + raw, err := rt.ReadFile(filepath.Join(dexDir, entry.Name())) + require.NoError(t, err) + require.NoError(t, repo.WriteIndex(ctx, entry.Name(), raw)) + } + local := keg.NewLocalKeg(repo, rt) + target := keg.NewApi("fixture", "local", "personal", keg.WithHubURL("https://fixture.invalid")) + local.SetTarget(&target) + return local +} + // runCLI executes a CLI command and returns stdout as a string. func (e *parityEnv) runCLI(args ...string) (string, error) { e.t.Helper() proc := sandbox.NewProcess(func(ctx context.Context, rt *toolkit.Runtime) (int, error) { + ctx = cli.WithTestDepsHook(ctx, func(deps *cli.Deps) { + deps.TapFactory = func(tapper.TapOptions) (*tapper.Tap, error) { return e.tap, nil } + }) return cli.Run(ctx, rt, args) }, false) // isTTY=false to get stdout output, not editor result := proc.Run(e.ctx, e.sb.Runtime()) @@ -128,6 +226,14 @@ func (e *parityEnv) runMCP(toolName string, args map[string]any) (string, error) return strings.TrimSpace(text), nil } +func (e *parityEnv) nodeHash(nodeID string) string { + e.t.Helper() + hash, err := e.tap.NodeHash(e.ctx, tapper.KegTargetOptions{}, nodeID) + require.NoError(e.t, err) + require.NotEmpty(e.t, hash) + return hash +} + type mcpError struct { msg string } diff --git a/pkg/parity/parity_write_test.go b/pkg/parity/parity_write_test.go index 39412db5..33360f4c 100644 --- a/pkg/parity/parity_write_test.go +++ b/pkg/parity/parity_write_test.go @@ -146,7 +146,10 @@ func TestParity_WriteOperations(t *testing.T) { // Remove via MCP. _, err = env.runMCP("remove", map[string]any{ - "node_ids": []string{nodeID}, + "nodes": []map[string]any{{ + "node_id": nodeID, + "expected_hash": env.nodeHash(nodeID), + }}, }) require.NoError(t, err, "MCP remove should succeed") @@ -214,8 +217,9 @@ func TestParity_WriteOperations(t *testing.T) { // Move via MCP. _, err = env.runMCP("move", map[string]any{ - "source_id": srcID, - "dest_id": "777", + "source_id": srcID, + "dest_id": "777", + "expected_hash": env.nodeHash(srcID), }) require.NoError(t, err, "MCP move should succeed") @@ -260,8 +264,9 @@ func TestParity_WriteOperations(t *testing.T) { // Edit via MCP. _, err = env.runMCP("edit", map[string]any{ "edits": []any{map[string]any{ - "node_id": nodeID, - "content": "# After MCP Edit\n\nEdited content.\n", + "node_id": nodeID, + "content": "# After MCP Edit\n\nEdited content.\n", + "expected_hash": env.nodeHash(nodeID), }}, }) require.NoError(t, err, "MCP edit should succeed") @@ -298,8 +303,9 @@ func TestParity_WriteOperations(t *testing.T) { // Write metadata via MCP. _, err = env.runMCP("meta", map[string]any{ "updates": []any{map[string]any{ - "node_id": nodeID, - "content": "tags:\n - updated-meta\n - parity\n", + "node_id": nodeID, + "content": "tags:\n - updated-meta\n - parity\n", + "expected_hash": env.nodeHash(nodeID), }}, }) require.NoError(t, err, "MCP meta write should succeed") @@ -340,8 +346,9 @@ func TestParity_WriteOperations(t *testing.T) { _, err = env.runMCP("meta", map[string]any{ "updates": []any{map[string]any{ - "node_id": nodeID, - "content": "id: \"" + nodeID + "\"\ntags:\n - round-trip\n", + "node_id": nodeID, + "content": "id: \"" + nodeID + "\"\ntags:\n - round-trip\n", + "expected_hash": env.nodeHash(nodeID), }}, }) require.NoError(t, err, "MCP meta write should succeed") @@ -353,8 +360,9 @@ func TestParity_WriteOperations(t *testing.T) { _, err = env.runMCP("meta", map[string]any{ "updates": []any{map[string]any{ - "node_id": nodeID, - "content": first, + "node_id": nodeID, + "content": first, + "expected_hash": env.nodeHash(nodeID), }}, }) require.NoError(t, err, "second MCP meta write should succeed") diff --git a/pkg/schemas/schemas.go b/pkg/schemas/schemas.go new file mode 100644 index 00000000..c842c1bd --- /dev/null +++ b/pkg/schemas/schemas.go @@ -0,0 +1,225 @@ +// Package schemas owns the JSON Schemas that back tap's editor modelines. +// +// Every YAML document tap hands to $EDITOR — and every config file it +// persists — carries a `# yaml-language-server: $schema=` modeline so a +// language server can offer completion, hover, and validation. Pointing that +// modeline at the published GitHub URL means the editor resolves whatever is +// on main, which is the wrong answer for anyone running a build that is ahead +// of (or behind) main, and no answer at all offline. +// +// Instead the schemas are embedded in the binary and materialized under the +// user's data dir on demand. The modeline then points at a file:// URI whose +// contents are guaranteed to match the binary that wrote it. Materialization +// compares content rather than versions, so a schema edit is picked up by the +// next command that writes a modeline. +// +// The published URLs remain the canonical $id values inside the schema files, +// and are the fallback whenever materialization is not possible. +package schemas + +import ( + "bytes" + "fmt" + "path/filepath" + "runtime" + "strings" + + "github.com/jlrickert/cli-toolkit/toolkit" + schemasfs "github.com/jlrickert/tapper/schemas" +) + +// Schema file names, as they appear both in the embedded FS and on disk. +const ( + TapConfig = "tap-config.json" + FlightManifest = "flight-manifest.json" + KegSettings = "keg-settings.json" + KegSchemaDefinition = "keg-schema-definition.json" +) + +// publicBase is where the schemas are published. It is the $id prefix used +// inside the schema documents themselves, and the modeline fallback when the +// embedded copy cannot be written to disk. +const publicBase = "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/" + +// Published URLs for each schema. Exported so pkg/tapper and pkg/keg can keep +// their long-standing *SchemaURL constants pointing at a single definition. +const ( + TapConfigURL = publicBase + TapConfig + FlightManifestURL = publicBase + FlightManifest + KegSettingsURL = publicBase + KegSettings + KegSchemaDefinitionURL = publicBase + KegSchemaDefinition +) + +// ModelinePrefix is the literal yaml-language-server directive. A modeline is +// this prefix followed by a schema URI, alone on its own line. +const ModelinePrefix = "# yaml-language-server: $schema=" + +// Names lists every embedded schema, in a stable order. +func Names() []string { + return []string{TapConfig, FlightManifest, KegSettings, KegSchemaDefinition} +} + +// PublicURL returns the published URL for a schema file name. +func PublicURL(name string) string { + return publicBase + name +} + +// Read returns the embedded bytes for a schema file name. +func Read(name string) ([]byte, error) { + data, err := schemasfs.FS.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("read embedded schema %s: %w", name, err) + } + return data, nil +} + +// Dir returns the directory the embedded schemas are materialized into: +// /tapper/schemas. Resolution flows through the runtime, so a +// sandboxed test gets its own directory rather than the developer's. +func Dir(rt *toolkit.Runtime) (string, error) { + dataDir, err := toolkit.UserDataPath(rt) + if err != nil { + return "", fmt.Errorf("resolve user data dir: %w", err) + } + return filepath.Join(dataDir, "tapper", "schemas"), nil +} + +// Materialize writes every embedded schema whose on-disk copy is missing or +// differs, and returns the directory holding them. Comparing content rather +// than a version stamp keeps it correct across development builds, where the +// version does not move but the schema does. +func Materialize(rt *toolkit.Runtime) (string, error) { + dir, err := Dir(rt) + if err != nil { + return "", err + } + if err := rt.Mkdir(dir, 0o755, true); err != nil { + return "", fmt.Errorf("create schema dir %s: %w", dir, err) + } + + for _, name := range Names() { + want, err := Read(name) + if err != nil { + return "", err + } + path := filepath.Join(dir, name) + if got, err := rt.ReadFile(path); err == nil && bytes.Equal(got, want) { + continue + } + if err := rt.AtomicWriteFile(path, want, 0o644); err != nil { + return "", fmt.Errorf("write schema %s: %w", path, err) + } + } + return dir, nil +} + +// ModelineURI returns the URI a modeline for name should point at: a file:// +// URI for the materialized copy, or the published URL when the schemas cannot +// be written (a read-only data dir, a constrained sandbox). It never fails — +// a stale modeline is a worse outcome than a remote one, and both are only +// comments. +func ModelineURI(rt *toolkit.Runtime, name string) string { + dir, err := Materialize(rt) + if err != nil { + return PublicURL(name) + } + return FileURI(filepath.Join(dir, name)) +} + +// Modeline returns the complete modeline line, newline included, for name. +func Modeline(rt *toolkit.Runtime, name string) string { + return ModelinePrefix + ModelineURI(rt, name) + "\n" +} + +// FileURI converts an absolute filesystem path to a file:// URI. Windows paths +// gain the extra leading slash (file:///C:/...) and forward slashes. +func FileURI(path string) string { + p := path + if runtime.GOOS == "windows" { + p = filepath.ToSlash(p) + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return "file://" + p +} + +// HasModeline reports whether data already carries a schema modeline in its +// leading comment block. Only the comments before the first content line are +// considered — a `# yaml-language-server:` string further down is data, not a +// directive. +func HasModeline(data []byte) bool { + for _, line := range bytes.Split(data, []byte("\n")) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + if bytes.HasPrefix(trimmed, []byte(ModelinePrefix)) { + return true + } + if bytes.HasPrefix(trimmed, []byte("#")) { + continue + } + return false + } + return false +} + +// EnsureModeline prepends modeline to data unless data already carries one. +func EnsureModeline(data []byte, modeline string) []byte { + if HasModeline(data) { + return data + } + out := make([]byte, 0, len(modeline)+len(data)) + out = append(out, modeline...) + out = append(out, data...) + return out +} + +// StripModeline removes the schema modeline from data's leading comment block, +// leaving everything else byte-for-byte. Documents that are stored rather than +// merely displayed run through this on the way in, so the modeline stays an +// editor affordance instead of becoming content: it names a path that is only +// meaningful on the machine that opened the editor. +func StripModeline(data []byte) []byte { + if !HasModeline(data) { + return data + } + + lines := bytes.Split(data, []byte("\n")) + for i, line := range lines { + trimmed := bytes.TrimSpace(line) + if bytes.HasPrefix(trimmed, []byte(ModelinePrefix)) { + return bytes.Join(append(lines[:i:i], lines[i+1:]...), []byte("\n")) + } + if len(trimmed) == 0 || bytes.HasPrefix(trimmed, []byte("#")) { + continue + } + break + } + return data +} + +// ReplaceModeline swaps whatever schema modeline data carries for modeline, +// prepending it when there is none. This is the choke point every write path +// runs its serialized YAML through: the serializers emit the published URL as +// a stable default, and the write path rewrites it to the local copy. +func ReplaceModeline(data []byte, modeline string) []byte { + if !HasModeline(data) { + return EnsureModeline(data, modeline) + } + + lines := bytes.Split(data, []byte("\n")) + for i, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 || (bytes.HasPrefix(trimmed, []byte("#")) && !bytes.HasPrefix(trimmed, []byte(ModelinePrefix))) { + continue + } + if !bytes.HasPrefix(trimmed, []byte(ModelinePrefix)) { + break + } + lines[i] = []byte(strings.TrimSuffix(modeline, "\n")) + return bytes.Join(lines, []byte("\n")) + } + return data +} diff --git a/pkg/schemas/schemas_test.go b/pkg/schemas/schemas_test.go new file mode 100644 index 00000000..025e93fb --- /dev/null +++ b/pkg/schemas/schemas_test.go @@ -0,0 +1,184 @@ +package schemas_test + +import ( + "path/filepath" + "testing" + + "github.com/jlrickert/cli-toolkit/sandbox" + "github.com/jlrickert/tapper/pkg/schemas" + "github.com/stretchr/testify/require" +) + +func newSandbox(t *testing.T) *sandbox.Sandbox { + t.Helper() + return sandbox.NewSandbox(t, &sandbox.Options{ + Home: filepath.FromSlash("/home/testuser"), + User: "testuser", + }) +} + +func TestMaterialize(t *testing.T) { + t.Parallel() + + t.Run("writes every embedded schema", func(t *testing.T) { + t.Parallel() + sb := newSandbox(t) + rt := sb.Runtime() + + dir, err := schemas.Materialize(rt) + require.NoError(t, err) + + want, err := schemas.Dir(rt) + require.NoError(t, err) + require.Equal(t, want, dir) + + for _, name := range schemas.Names() { + embedded, err := schemas.Read(name) + require.NoError(t, err) + + onDisk, err := rt.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err, "schema %s should have been written", name) + require.Equal(t, embedded, onDisk, "schema %s should match the embedded copy", name) + } + }) + + t.Run("is idempotent", func(t *testing.T) { + t.Parallel() + sb := newSandbox(t) + rt := sb.Runtime() + + first, err := schemas.Materialize(rt) + require.NoError(t, err) + second, err := schemas.Materialize(rt) + require.NoError(t, err) + require.Equal(t, first, second) + + embedded, err := schemas.Read(schemas.TapConfig) + require.NoError(t, err) + onDisk, err := rt.ReadFile(filepath.Join(second, schemas.TapConfig)) + require.NoError(t, err) + require.Equal(t, embedded, onDisk) + }) + + t.Run("restores a schema whose content drifted", func(t *testing.T) { + t.Parallel() + sb := newSandbox(t) + rt := sb.Runtime() + + dir, err := schemas.Materialize(rt) + require.NoError(t, err) + + // Simulate an older build's copy: same path, stale bytes. Comparing + // content (not a version stamp) is what makes this recoverable. + target := filepath.Join(dir, schemas.FlightManifest) + require.NoError(t, rt.AtomicWriteFile(target, []byte(`{"stale": true}`), 0o644)) + + _, err = schemas.Materialize(rt) + require.NoError(t, err) + + embedded, err := schemas.Read(schemas.FlightManifest) + require.NoError(t, err) + onDisk, err := rt.ReadFile(target) + require.NoError(t, err) + require.Equal(t, embedded, onDisk) + }) +} + +func TestModelineURI(t *testing.T) { + t.Parallel() + + t.Run("points at the materialized copy", func(t *testing.T) { + t.Parallel() + sb := newSandbox(t) + rt := sb.Runtime() + + dir, err := schemas.Dir(rt) + require.NoError(t, err) + + uri := schemas.ModelineURI(rt, schemas.TapConfig) + require.Equal(t, schemas.FileURI(filepath.Join(dir, schemas.TapConfig)), uri) + + // Resolving the modeline is the whole point — the file must be there. + _, err = rt.ReadFile(filepath.Join(dir, schemas.TapConfig)) + require.NoError(t, err) + }) + + t.Run("falls back to the published URL when materialization fails", func(t *testing.T) { + t.Parallel() + sb := newSandbox(t) + rt := sb.Runtime() + + // A plain file where the schema directory belongs: Mkdir cannot + // proceed, so ModelineURI has to degrade instead of failing. + dir, err := schemas.Dir(rt) + require.NoError(t, err) + require.NoError(t, rt.Mkdir(filepath.Dir(dir), 0o755, true)) + require.NoError(t, rt.AtomicWriteFile(dir, []byte("not a directory"), 0o644)) + + _, err = schemas.Materialize(rt) + require.Error(t, err) + + require.Equal(t, schemas.TapConfigURL, schemas.ModelineURI(rt, schemas.TapConfig)) + }) +} + +func TestModelineHelpers(t *testing.T) { + t.Parallel() + + const modeline = schemas.ModelinePrefix + "file:///tmp/tap-config.json\n" + + t.Run("HasModeline only inspects the leading comment block", func(t *testing.T) { + t.Parallel() + require.True(t, schemas.HasModeline([]byte(schemas.ModelinePrefix+"x\ntitle: a\n"))) + require.True(t, schemas.HasModeline([]byte("# header\n"+schemas.ModelinePrefix+"x\ntitle: a\n"))) + require.False(t, schemas.HasModeline([]byte("title: a\n"))) + // Past the first content line it is data, not a directive. + require.False(t, schemas.HasModeline([]byte("title: a\n"+schemas.ModelinePrefix+"x\n"))) + }) + + t.Run("EnsureModeline prepends only when absent", func(t *testing.T) { + t.Parallel() + require.Equal(t, []byte(modeline+"title: a\n"), + schemas.EnsureModeline([]byte("title: a\n"), modeline)) + + existing := []byte(schemas.ModelinePrefix + "https://example.test/s.json\ntitle: a\n") + require.Equal(t, existing, schemas.EnsureModeline(existing, modeline)) + }) + + t.Run("StripModeline removes only the directive line", func(t *testing.T) { + t.Parallel() + require.Equal(t, []byte("title: a\n"), + schemas.StripModeline([]byte(modeline+"title: a\n"))) + require.Equal(t, []byte("# header\ntitle: a\n"), + schemas.StripModeline([]byte("# header\n"+modeline+"title: a\n"))) + // Nothing to strip leaves the bytes untouched. + require.Equal(t, []byte("title: a\n"), schemas.StripModeline([]byte("title: a\n"))) + // Past the first content line it is data, not a directive. + body := []byte("title: a\n" + schemas.ModelinePrefix + "x\n") + require.Equal(t, body, schemas.StripModeline(body)) + }) + + t.Run("StripModeline undoes ReplaceModeline", func(t *testing.T) { + t.Parallel() + body := []byte("kegv: \"2025-07\"\ntitle: a\n") + require.Equal(t, body, schemas.StripModeline(schemas.ReplaceModeline(body, modeline))) + }) + + t.Run("ReplaceModeline swaps the URI in place", func(t *testing.T) { + t.Parallel() + in := []byte(schemas.ModelinePrefix + schemas.TapConfigURL + "\ntitle: a\n") + require.Equal(t, []byte(modeline+"title: a\n"), schemas.ReplaceModeline(in, modeline)) + }) + + t.Run("ReplaceModeline preserves comments above the modeline", func(t *testing.T) { + t.Parallel() + in := []byte("# header\n" + schemas.ModelinePrefix + schemas.TapConfigURL + "\ntitle: a\n") + require.Equal(t, []byte("# header\n"+modeline+"title: a\n"), schemas.ReplaceModeline(in, modeline)) + }) + + t.Run("ReplaceModeline prepends when there is nothing to replace", func(t *testing.T) { + t.Parallel() + require.Equal(t, []byte(modeline+"title: a\n"), + schemas.ReplaceModeline([]byte("title: a\n"), modeline)) + }) +} diff --git a/pkg/tapper/alias.go b/pkg/tapper/alias.go index 91c8de49..c7e39355 100644 --- a/pkg/tapper/alias.go +++ b/pkg/tapper/alias.go @@ -7,9 +7,9 @@ import ( "github.com/jlrickert/tapper/pkg/keg" ) -// kegAliasPattern restricts keg aliases to a portable, filesystem-safe shape. -// Lowercase letters, digits, hyphen, and underscore — no dots, slashes, -// whitespace, or case variants that differ across platforms (HFS+, FAT32). +// kegAliasPattern restricts keg aliases to the portable Hub route shape. +// Lowercase letters, digits, hyphen, and underscore are accepted; dots, +// slashes, whitespace, and uppercase variants are rejected. var kegAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`) // ValidateKegAlias returns nil when alias matches the canonical alias shape @@ -27,11 +27,9 @@ func ValidateKegAlias(alias string) error { return nil } -// namespacePattern restricts namespaces to a portable, filesystem-safe single -// path segment: lowercase letters, digits, hyphen, underscore. The absence of a -// dot is load-bearing — it guarantees a namespace directory /@ -// can never collide with a reserved sentinel directory such as flights.d (which -// holds local flight manifests beside the @ dirs). +// namespacePattern restricts namespaces to a single Hub route segment: +// lowercase letters, digits, hyphen, and underscore. The leading sigil +// distinguishes a namespace from ordinary aliases in references and Hub routes. var namespacePattern = regexp.MustCompile(`^[a-z0-9_-]+$`) // ValidateNamespace returns nil when ns is a legal namespace segment and a diff --git a/pkg/tapper/auth_flow.go b/pkg/tapper/auth_flow.go index d99057de..edb4b827 100644 --- a/pkg/tapper/auth_flow.go +++ b/pkg/tapper/auth_flow.go @@ -85,6 +85,14 @@ func CanonicalHubURL(s string) string { return strings.TrimRight(parsed.String(), "/") } +// CanonicalConfiguredHubURL returns the AuthStore identity for a configured +// hub URL. Config accepts bare hosts and gives them an HTTPS default, while +// CanonicalHubURL deliberately operates only on the URL it is handed. Keep +// that distinction explicit at alias-to-auth identity boundaries. +func CanonicalConfiguredHubURL(s string) string { + return CanonicalHubURL(hubURLWithScheme(s)) +} + // discoverAuthServerMetadata fetches RFC 8414 authorization server // metadata from the hub. Tapper requires the hub to advertise its // endpoints explicitly rather than assuming path conventions — a POST diff --git a/pkg/tapper/auth_flow_test.go b/pkg/tapper/auth_flow_test.go index cedcc80d..8ce574c3 100644 --- a/pkg/tapper/auth_flow_test.go +++ b/pkg/tapper/auth_flow_test.go @@ -21,3 +21,17 @@ func TestCanonicalHubURL(t *testing.T) { require.Equal(t, tc.want, tapper.CanonicalHubURL(tc.in), "in=%q", tc.in) } } + +func TestCanonicalConfiguredHubURL(t *testing.T) { + t.Parallel() + cases := []struct { + in, want string + }{ + {"Tapper-2-JLRickert:8445", "https://tapper-2-jlrickert:8445"}, + {"HTTPS://Tapper-2-JLRickert:8445/Hub/", "https://tapper-2-jlrickert:8445/Hub"}, + {"http://Tapper-2-JLRickert:8080/base/", "http://tapper-2-jlrickert:8080/base"}, + } + for _, tc := range cases { + require.Equal(t, tc.want, tapper.CanonicalConfiguredHubURL(tc.in), "in=%q", tc.in) + } +} diff --git a/pkg/tapper/auth_resolver.go b/pkg/tapper/auth_resolver.go index 88e6907e..7af648d7 100644 --- a/pkg/tapper/auth_resolver.go +++ b/pkg/tapper/auth_resolver.go @@ -132,7 +132,7 @@ func ResolveLoginHubURL(cfg *Config, explicit string) (string, error) { if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { + if kind != HubKindRemote && kind != HubKindReadonly { continue } remoteName = name @@ -163,8 +163,8 @@ func loginHubURLFromEntry(label, name string, entry HubEntry) (string, error) { if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { - return "", fmt.Errorf("auth: %s %q is local and cannot be used for auth login", label, name) + if kind != HubKindRemote && kind != HubKindReadonly { + return "", fmt.Errorf("auth: %s %q has unsupported kind %q", label, name, kind) } if strings.TrimSpace(entry.URL) == "" { return "", fmt.Errorf("auth: %s %q has no URL configured", label, name) diff --git a/pkg/tapper/auth_resolver_chain_test.go b/pkg/tapper/auth_resolver_chain_test.go index 1a0b11cb..226be74f 100644 --- a/pkg/tapper/auth_resolver_chain_test.go +++ b/pkg/tapper/auth_resolver_chain_test.go @@ -65,9 +65,9 @@ func TestResolveLoginHubURL(t *testing.T) { want: "https://backup.example.com", }, { - name: "step 3: FallbackHub local entry → error", + name: "step 3: FallbackHub unsupported entry → error", yaml: "fallbackHub: home\nhubs:\n home:\n kind: local\n basePath: /tmp/kegs\n", - errMatch: `fallback hub "home" is local`, + errMatch: `fallback hub "home" has unsupported kind "local"`, }, { name: "step 4: exactly one remote Hubs entry, no DefaultHub or FallbackHub", diff --git a/pkg/tapper/auth_resolver_test.go b/pkg/tapper/auth_resolver_test.go index 8de9d8b7..f8c1f9e1 100644 --- a/pkg/tapper/auth_resolver_test.go +++ b/pkg/tapper/auth_resolver_test.go @@ -67,18 +67,6 @@ func TestAuthStoreTokenResolver_ResolveToken(t *testing.T) { target: keg.Target{Hub: "alt", Namespace: "me", KegName: "demo", HubURL: "https://" + altHubHost}, want: altHubToken, }, - { - name: "file target short-circuits to empty", - store: newStore(), - target: keg.Target{File: "/tmp/keg"}, - want: "", - }, - { - name: "memory target short-circuits to empty", - store: newStore(), - target: keg.Target{Memory: true, KegName: "m"}, - want: "", - }, { name: "nil store yields empty for every input", store: nil, diff --git a/pkg/tapper/config.go b/pkg/tapper/config.go index 09893cc6..f17678a9 100644 --- a/pkg/tapper/config.go +++ b/pkg/tapper/config.go @@ -15,12 +15,17 @@ import ( "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/schemas" "gopkg.in/yaml.v3" ) const ( - TapConfigSchemaURL = "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/tap-config.json" - tapConfigSchemaModeline = "# yaml-language-server: $schema=" + TapConfigSchemaURL + "\n" + TapConfigSchemaURL = schemas.TapConfigURL + + // tapConfigSchemaModeline is what ToYAML emits by default: the published + // URL, which is meaningful anywhere. Write paths that hold a runtime swap + // it for the materialized local copy via schemas.ReplaceModeline. + tapConfigSchemaModeline = schemas.ModelinePrefix + TapConfigSchemaURL + "\n" // DefaultHubName is the compiled-in name of the default remote hub. DefaultHubName = "atlas" @@ -37,19 +42,12 @@ const ( // DefaultHubTokenEnv is the environment variable the default remote hub // reads its bearer token from when no explicit credential is configured. DefaultHubTokenEnv = "ATLAS_API_KEY" - - // LocalHubName is the reserved name of the built-in filesystem hub. Kegs - // addressed at the "local" hub (or with namespace "local" and no hub) live - // on disk under the hub's basePath rather than on a remote service. - LocalHubName = "local" ) // Hub kinds describe how a hub's (namespace, name) pairs are backed. const ( // HubKindRemote is a read-write HTTP hub (the default, e.g. atlas). HubKindRemote = "remote" - // HubKindLocal is a filesystem-backed hub on this machine. - HubKindLocal = "local" // HubKindReadonly is a read-only HTTP hub. TODO: enforce read-only writes // in the API repository; today the kind only sets Target.Readonly. HubKindReadonly = "readonly" @@ -66,7 +64,7 @@ type configDTO struct { Updated time.Time `yaml:"updated,omitempty"` // defaultKeg is the keg reference used when no explicit keg is provided. It - // is a keg selector (a bare name, @namespace/name, keg:..., or a path), + // is a remote keg selector (a bare name, @namespace/name, or keg:...), // resolved through the namespace-centric ResolveRef chain. DefaultKeg string `yaml:"defaultKeg,omitempty"` @@ -77,16 +75,16 @@ type configDTO struct { // flight is the flight context applied when no --flight flag is given. It is // a flight reference (@namespace/+slug, +slug, or a bare slug) and is // may be set as a user baseline by bootstrap or overridden in project config; - // TAP_FLIGHT, the active agent's flight, and --flight have higher precedence. + // TAP_FLIGHT and an explicit --flight have higher precedence. Agent entries + // never participate in flight selection. Flight string `yaml:"flight,omitempty"` - // agent names the entry in agents{} driving this process, and is set by - // `tap launch` as TAP_AGENT. It selects a flight indirectly: resolution reads - // agents[agent].flight out of the merged config on every load, so an edit to - // the agent's flight is picked up by the next reload. Exporting the resolved - // flight instead would freeze it for the life of the process, which is - // precisely the bug this field exists to avoid. TAP_FLIGHT and --flight, - // being direct, still outrank it. + // agent names the entry in agents{} driving this process. It serves two + // directions: `tap launch` exports the agent it resolved here as TAP_AGENT + // so the child can report its own identity, and `tap launch` reads it as the + // default when --agent is omitted (mirroring flight/TAP_FLIGHT). It selects + // a model and supplies telemetry only; flight selection is independent and + // TAP_FLIGHT pins a launch root. Agent string `yaml:"agent,omitempty"` // kegMap maps a project path or pattern to a keg reference. @@ -124,11 +122,6 @@ type configDTO struct { // explicit atlas entry in hubs{} is unaffected (explicit always wins). DisableAtlasHub bool `yaml:"disableAtlasHub,omitempty"` - // disableLocalHub turns off the synthesized built-in local filesystem hub, - // symmetric with disableAtlasHub. An explicit local hub entry in hubs{} (or - // the hostname-keyed local hub tap bootstrap writes) is unaffected. - DisableLocalHub bool `yaml:"disableLocalHub,omitempty"` - // disableTelemetry opts this user out of privacy-minimized invocation // reporting to their authenticated remote hub. Reporting is enabled when // this field is unset or false. @@ -137,18 +130,21 @@ type configDTO struct { // hubs describes configured hubs available to the user, keyed by name. Hubs hubMap `yaml:"hubs,omitempty"` - // agents names (model, flight) pairs for `tap launch`, keyed by alias. + // agents names model definitions for `tap launch`, keyed by alias. // Experimental and undocumented; see tap_launch.go. Agents map[string]AgentEntry `yaml:"agents,omitempty"` } // Config represents the user's tapper configuration. // -// Config is a data-only model. We do not preserve YAML comments or original -// document formatting. +// Config keeps a typed model alongside its parsed YAML document. Tapper-owned +// rewrites overlay known values so extension fields and comments survive. type Config struct { // parsed data. data *configDTO + // doc retains the parsed YAML document so Tapper-owned rewrites can overlay + // known fields without discarding extension fields it does not understand. + doc *yaml.Node } // KegMapEntry is an entry mapping a path prefix or regex to a keg alias. @@ -161,9 +157,7 @@ type KegMapEntry struct { // HubEntry describes a single configured hub, keyed by name in the hubs map. // // Kind selects the backend: "remote" (read-write HTTP, the default when Kind -// is empty), "local" (filesystem) or "readonly" (read-only HTTP). Remote and -// readonly hubs use URL; local hubs use BasePath as the filesystem root that -// holds @/ keg directories. +// is empty) or "readonly" (read-only HTTP). type HubEntry struct { Kind string `yaml:"kind,omitempty"` // DefaultNamespace is this hub's default namespace, used when a reference @@ -171,18 +165,19 @@ type HubEntry struct { // this is only the default. The "@" sigil is implied — store the bare value. DefaultNamespace string `yaml:"defaultNamespace,omitempty"` URL string `yaml:"url,omitempty"` - BasePath string `yaml:"basePath,omitempty"` Token string `yaml:"token,omitempty"` TokenEnv string `yaml:"tokenEnv,omitempty"` } -// AgentEntry is an alias for a (model, flight) pair plus how to reach and +// AgentEntry is an alias for a model plus how to reach and // authenticate against that model, keyed by name in the agents map and consumed // by `tap launch`. // // Model is provider-qualified ("anthropic/claude-opus-4", "ollama/qwen3.6:35b") -// so the launcher knows which protocol the harness must speak. Flight is a -// flight reference exported to the launched process as TAP_FLIGHT. +// so the launcher knows which protocol the harness must speak. Launch roots +// come from the top-level flight cascade; legacy per-agent flight keys are +// ignored and preserved as unknown extension data when configuration is +// rewritten. // // BaseURL overrides the provider's endpoint. One value serves both protocols: // the launcher adds or removes the /v1 suffix to suit whichever the harness @@ -202,7 +197,6 @@ type HubEntry struct { // raw flag, and reports rather than drops it where there is no equivalent. type AgentEntry struct { Model string `yaml:"model,omitempty"` - Flight string `yaml:"flight,omitempty"` BaseURL string `yaml:"baseUrl,omitempty"` Auth string `yaml:"auth,omitempty"` APIKeyEnv string `yaml:"apiKeyEnv,omitempty"` @@ -213,23 +207,17 @@ type AgentEntry struct { // KegRef is the (hub, namespace, name) triple a keg alias resolves to. An empty // Hub falls back to defaultHub/fallbackHub; an empty Namespace falls back to // defaultNamespace/fallbackNamespace (see Config.ResolveRef). -// -// Path addresses a keg by an explicit local filesystem path. When set it takes -// precedence over the triple and resolves to a file target at that path. The -// triple addresses a keg by namespace; Path addresses one directly on disk. type KegRef struct { Hub string `yaml:"hub,omitempty"` Namespace string `yaml:"namespace,omitempty"` Name string `yaml:"name,omitempty"` - Path string `yaml:"path,omitempty"` } -// UnmarshalYAML accepts the canonical mapping form ({hub, namespace, name, -// path}) and a scalar shorthand parsed via keg.Parse — the canonical keg -// shorthand "keg:@ns/name" sets {namespace, name} (the hub is resolved from the -// namespace, never encoded), while a bare file/url scalar maps onto a Path. To -// pin a hub, use the mapping form's "hub" field. Writes always serialize the -// canonical mapping form. +// UnmarshalYAML accepts the canonical mapping form ({hub, namespace, name}) and +// remote scalar shorthand. The canonical shorthand "keg:@ns/name" sets +// {namespace, name}; the hub is resolved from the namespace and is never +// encoded. To pin a hub, use the mapping form's "hub" field. Writes always +// serialize the canonical mapping form. func (r *KegRef) UnmarshalYAML(node *yaml.Node) error { if node == nil { return nil @@ -240,7 +228,6 @@ func (r *KegRef) UnmarshalYAML(node *yaml.Node) error { Hub string `yaml:"hub"` Namespace string `yaml:"namespace"` Name string `yaml:"name"` - Path string `yaml:"path"` } var x rawRef if err := node.Decode(&x); err != nil { @@ -249,31 +236,18 @@ func (r *KegRef) UnmarshalYAML(node *yaml.Node) error { r.Hub = strings.TrimSpace(x.Hub) r.Namespace = strings.TrimSpace(x.Namespace) r.Name = strings.TrimSpace(x.Name) - r.Path = strings.TrimSpace(x.Path) return nil case yaml.ScalarNode: s := strings.TrimSpace(node.Value) if s == "" { return nil } - t, err := keg.Parse(s) - if err != nil { + ref := parseKegRef(s) + if ref.Name == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, "~") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "file://") { + _, err := keg.Parse(s) return fmt.Errorf("decode keg ref scalar %q: %w", s, err) } - switch { - case t.KegName != "": - // Keg reference: canonical "keg:@ns/name" (hub resolved from the - // namespace) or a structured scalar carrying a hub pin. - r.Hub = t.Hub - r.Namespace = t.Namespace - r.Name = t.KegName - case t.File != "": - // File-path scalar → explicit local path. - r.Path = t.File - default: - // Any other scalar (e.g. a bare URL): keep verbatim. - r.Path = s - } + *r = ref return nil default: return fmt.Errorf("unsupported yaml node kind %d for keg ref", node.Kind) @@ -421,15 +395,6 @@ func (cfg *Config) DisableAtlasHub() bool { return cfg.data.DisableAtlasHub } -// DisableLocalHub returns true when the synthesized built-in local hub is -// suppressed. -func (cfg *Config) DisableLocalHub() bool { - if cfg.data == nil { - cfg.data = &configDTO{} - } - return cfg.data.DisableLocalHub -} - // DisableTelemetry returns true when remote invocation reporting is disabled. func (cfg *Config) DisableTelemetry() bool { if cfg.data == nil { @@ -482,12 +447,8 @@ func (cfg *Config) Agent(name string) (AgentEntry, bool) { return e, ok } -// Hub returns the named hub entry. The built-in hubs "local" (filesystem) and -// "atlas" (the default remote hub) are synthesized when not explicitly -// configured — unless disabled via disableLocalHub / disableAtlasHub, in which -// case the synthesized built-in is suppressed and Hub reports it as not found. -// An explicit configuration in hubs{} always wins over (and is unaffected by the -// disable flag for) the built-in. +// Hub returns the named hub entry. The built-in atlas remote hub is synthesized +// when it is not explicitly configured unless disableAtlasHub is set. func (cfg *Config) Hub(name string) (HubEntry, bool) { name = strings.TrimSpace(name) if name == "" { @@ -497,11 +458,6 @@ func (cfg *Config) Hub(name string) (HubEntry, bool) { return e, true } switch name { - case LocalHubName: - if cfg.DisableLocalHub() { - return HubEntry{}, false - } - return HubEntry{Kind: HubKindLocal}, true case DefaultHubName: if cfg.DisableAtlasHub() { return HubEntry{}, false @@ -658,15 +614,6 @@ func (cfg *Config) SetDisableTelemetry(disable bool) error { return nil } -// SetDisableLocalHub toggles the synthesized built-in local hub. -func (cfg *Config) SetDisableLocalHub(disable bool) error { - if cfg.data == nil { - cfg.data = &configDTO{} - } - cfg.data.DisableLocalHub = disable - return nil -} - // SetHub adds or replaces a hub entry by name. func (cfg *Config) SetHub(name string, entry HubEntry) error { if cfg == nil { @@ -756,8 +703,8 @@ func (cfg *Config) Clone() *Config { // resolveNamespaceForName applies namespace precedence for a keg name in the // namespace-centric model: defaultNamespace → fallbackNamespace. It returns "" -// when neither applies, leaving the per-hub default and the local-hub fallback -// in ResolveRef to have the final say once the hub kind is known. +// when neither applies, leaving the per-hub default in ResolveRef to have the +// final say once the hub is known. func (cfg *Config) resolveNamespaceForName() string { if ns := strings.TrimSpace(cfg.DefaultNamespace()); ns != "" { return ns @@ -769,8 +716,7 @@ func (cfg *Config) resolveNamespaceForName() string { } // resolveHubForNamespace applies hub precedence for a namespace: an explicit -// namespaces[ns].Hub mapping → this machine's filesystem hub for the reserved -// "local" namespace → the general hub precedence chain (defaultHub → +// namespaces[ns].Hub mapping → the general hub precedence chain (defaultHub → // fallbackHub → sole/alphabetically-first hub → the compiled-in default hub). func (cfg *Config) resolveHubForNamespace(ns string) string { ns = strings.TrimSpace(ns) @@ -781,9 +727,6 @@ func (cfg *Config) resolveHubForNamespace(ns string) string { } } } - if ns == LocalHubName { - return cfg.localHubName() - } return cfg.resolveHubName() } @@ -814,58 +757,29 @@ func (cfg *Config) resolveHubName() string { return DefaultHubName } -// localHubName returns the name of the local (filesystem) hub used when the -// reserved "local" namespace pins a reference to this machine. It prefers -// defaultHub when that hub is local, otherwise the alphabetically-first -// local-kind hub, and falls back to the reserved LocalHubName when no local hub -// is configured (Config.Hub synthesizes the built-in "local" hub in that case). -func (cfg *Config) localHubName() string { - if h := strings.TrimSpace(cfg.DefaultHub()); h != "" { - if e, ok := cfg.Hubs()[h]; ok && strings.TrimSpace(e.Kind) == HubKindLocal { - return h - } - } - names := make([]string, 0) - for n, e := range cfg.Hubs() { - if strings.TrimSpace(e.Kind) == HubKindLocal { - names = append(names, n) - } - } - if len(names) > 0 { - sort.Strings(names) - return names[0] - } - if cfg.DisableLocalHub() { - return "" - } - return LocalHubName -} - // resolveNamespaceHub resolves the effective (namespace, hubName, entry) for a // reference whose namespace and/or hub may be empty. It is the single source of // truth for the namespace-centric chain, shared by ResolveRef (backend // resolution), resolveIdentity (display) and resolveKegAdminRef (admin): // // namespace: explicit → defaultNamespace → fallbackNamespace → the resolved -// hub's per-hub defaultNamespace → @local (for a local hub) -// hub: explicit → namespaces[ns].hub → @local→local hub → defaultHub → -// fallbackHub → sole/alpha hub → compiled-in atlas (unless disabled) +// hub's per-hub defaultNamespace +// hub: explicit → namespaces[ns].hub → defaultHub → fallbackHub → +// sole/alpha hub → compiled-in atlas (unless disabled) // // It returns an error when no hub is available (a disabled built-in with nothing -// else configured), the resolved hub is not configured, or a non-local hub has -// no resolvable namespace. Callers wrap the error with reference context. +// else configured), the resolved hub is not configured, or the hub has no +// resolvable namespace. Callers wrap the error with reference context. func (cfg *Config) resolveNamespaceHub(ns, hubName string) (string, string, HubEntry, error) { // Namespace first: explicit → default → fallback. It may still be empty - // here; the per-hub default and the local-hub fallback below get the final - // say once the hub kind is known. + // here; the per-hub default below gets the final say once the hub is known. ns = strings.TrimSpace(ns) if ns == "" { ns = cfg.resolveNamespaceForName() } // Hub from the namespace: explicit wins; otherwise the namespace→hub map - // pins it, the reserved "local" namespace selects this machine's filesystem - // hub, and finally the hub precedence chain applies. + // pins it, and finally the hub precedence chain applies. hubName = strings.TrimSpace(hubName) if hubName == "" { hubName = cfg.resolveHubForNamespace(ns) @@ -884,18 +798,17 @@ func (cfg *Config) resolveNamespaceHub(ns, hubName string) (string, string, HubE } // Last-resort namespace once the hub is known: the hub's own default - // namespace (lower precedence than the default/fallback chain), then @local - // for a local hub, else an error. + // namespace (lower precedence than the default/fallback chain), else an error. if ns == "" { - switch { - case strings.TrimSpace(entry.DefaultNamespace) != "": + if strings.TrimSpace(entry.DefaultNamespace) != "" { ns = strings.TrimSpace(entry.DefaultNamespace) - case kind == HubKindLocal: - ns = LocalHubName - default: + } else { return "", "", HubEntry{}, fmt.Errorf("no namespace and no per-hub, default, or fallback namespace is configured") } } + if kind != HubKindRemote && kind != HubKindReadonly { + return "", "", HubEntry{}, fmt.Errorf("hub %q has unsupported kind %q", hubName, kind) + } return ns, hubName, entry, nil } @@ -905,21 +818,9 @@ func (cfg *Config) resolveNamespaceHub(ns, hubName string) (string, string, HubE // namespace first (explicit → default/fallback), then the hub that hosts that // namespace — and the per-kind backend mapping: // -// - local: /@/ as a file target // - remote: /api/v1/@/kegs/ as a hub target // - readonly: same URL as remote, with Target.Readonly set -func (cfg *Config) ResolveRef(rt *toolkit.Runtime, ref KegRef) (*keg.Target, error) { - // An explicit local path addresses a file keg directly and takes precedence - // over the namespace triple. - if p := strings.TrimSpace(ref.Path); p != "" { - p = toolkit.ExpandEnv(rt, p) - if expanded, err := toolkit.ExpandPath(rt, p); err == nil { - p = expanded - } - t := keg.NewFile(p) - return &t, nil - } - +func (cfg *Config) ResolveRef(_ *toolkit.Runtime, ref KegRef) (*keg.Target, error) { name := strings.TrimSpace(ref.Name) if name == "" { return nil, fmt.Errorf("keg reference is missing a name") @@ -939,22 +840,6 @@ func (cfg *Config) ResolveRef(rt *toolkit.Runtime, ref KegRef) (*keg.Target, err } switch kind { - case HubKindLocal: - base := strings.TrimSpace(entry.BasePath) - if base == "" { - root, err := defaultUserKegRoot(rt) - if err != nil { - return nil, fmt.Errorf("local hub %q has no basePath and the platform default is unavailable: %w", hubName, err) - } - base = root - } - base = toolkit.ExpandEnv(rt, base) - if expanded, err := toolkit.ExpandPath(rt, base); err == nil { - base = expanded - } - path := filepath.Join(base, "@"+ns, name) - t := keg.NewFile(path) - return &t, nil case HubKindRemote, HubKindReadonly: url := strings.TrimSpace(entry.URL) if url == "" { @@ -980,8 +865,7 @@ func (cfg *Config) ResolveRef(rt *toolkit.Runtime, ref KegRef) (*keg.Target, err // - "keg:@ns/name" / "keg:name" — the canonical keg scheme (parsed by // keg.Parse; the hub is resolved from the namespace, never encoded). // - "@ns/name" — a namespace-qualified reference. -// - a filesystem path — "/abs", "~/p", "./p", "../p", or a "://" -// URL: kept verbatim as KegRef.Path (explicit file-keg addressing). +// - an HTTP(S) endpoint — used directly as a remote target. // - "name" — a bare keg name; its namespace and hub are // supplied by ResolveRef's default/fallback chains. // @@ -1011,11 +895,8 @@ func parseKegRef(s string) KegRef { // Canonical keg scheme: defer to the shared parser. if strings.HasPrefix(s, keg.SchemeAlias+":") { if t, err := keg.Parse(s); err == nil { - switch { - case t.KegName != "": + if t.KegName != "" { return KegRef{Hub: t.Hub, Namespace: t.Namespace, Name: t.KegName} - case t.File != "": - return KegRef{Path: t.File} } } // Malformed keg: ref falls through to be treated as a bare name so @@ -1032,11 +913,6 @@ func parseKegRef(s string) KegRef { } // Malformed @-ref falls through to a bare name. } - // Explicit filesystem-path keg. - if strings.HasPrefix(s, "/") || strings.HasPrefix(s, "~") || - strings.HasPrefix(s, ".") || strings.Contains(s, "://") { - return KegRef{Path: s} - } // Bare keg name: namespace and hub come from the default/fallback chains. return KegRef{Name: s} } @@ -1053,6 +929,11 @@ func (cfg *Config) ResolveAlias(rt *toolkit.Runtime, alias string) (*keg.Target, if strings.TrimSpace(alias) == "" { return nil, fmt.Errorf("no keg reference given") } + if target, err := keg.Parse(alias); err == nil && (target.Scheme() == keg.SchemeHTTP || target.Scheme() == keg.SchemeHTTPs) { + return target, nil + } else if strings.HasPrefix(alias, "/") || strings.HasPrefix(alias, "~") || strings.HasPrefix(alias, ".") || strings.HasPrefix(alias, "file://") { + return nil, err + } return cfg.ResolveRef(rt, parseKegRef(alias)) } @@ -1143,9 +1024,14 @@ func (cfg *Config) ResolveDefault(rt *toolkit.Runtime) (*keg.Target, error) { // ignored by the decoder. func ParseConfig(raw []byte) (*Config, error) { uc := &Config{data: &configDTO{}} + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("failed to parse user config yaml: %w", err) + } if err := yaml.Unmarshal(raw, uc.data); err != nil { return nil, fmt.Errorf("failed to parse user config yaml: %w", err) } + uc.doc = &doc return uc, nil } @@ -1191,19 +1077,14 @@ func stripUntrustedFields(cfg *Config) []string { // // The global user config uses the FALLBACK hub (fallbackHub) so keg references // need not specify a hub. The namespace is NOT pinned by a global -// fallbackNamespace — it comes from the resolved hub's own namespace field, so -// `name` seeds the remote hub's default namespace while the local hub keeps the -// reserved @local. The default remote hub (atlas) and the built-in local hub are -// registered, the local namespace maps to the local hub, and localKegRoot seeds -// the local hub's basePath. -func DefaultUserConfig(name string, localKegRoot string) *Config { +// fallbackNamespace — it comes from the resolved hub's own namespace field. +// `name` seeds the default remote hub's namespace. +func DefaultUserConfig(name string) *Config { return &Config{ data: &configDTO{ FallbackHub: DefaultHubName, KegMap: []KegMapEntry{}, - Namespaces: map[string]NamespaceRef{ - LocalHubName: {Hub: LocalHubName}, - }, + Namespaces: map[string]NamespaceRef{}, Hubs: hubMap{ DefaultHubName: { Kind: HubKindRemote, @@ -1211,11 +1092,6 @@ func DefaultUserConfig(name string, localKegRoot string) *Config { URL: DefaultHubURL, TokenEnv: DefaultHubTokenEnv, }, - LocalHubName: { - Kind: HubKindLocal, - DefaultNamespace: LocalHubName, - BasePath: localKegRoot, - }, }, }, } @@ -1228,7 +1104,7 @@ func DefaultUserConfig(name string, localKegRoot string) *Config { func DefaultProjectConfig(user, userKegRepo string) *Config { alias := strings.TrimSpace(user) if alias == "" { - alias = LocalHubName + alias = "project" } return &Config{ data: &configDTO{ @@ -1249,14 +1125,14 @@ func DefaultProjectConfig(user, userKegRepo string) *Config { // INERT file — none of the authoritative default* slots are active, so a stray // project config can't silently override user-level keg/namespace/hub // resolution. Parsing it yields an empty Config. -func projectConfigTemplate() ([]byte, error) { +func projectConfigTemplate(rt *toolkit.Runtime) ([]byte, error) { example := DefaultProjectConfig("project", "kegs") body, err := yaml.Marshal(example.data) if err != nil { return nil, fmt.Errorf("render project config template: %w", err) } var b strings.Builder - b.WriteString(tapConfigSchemaModeline) + b.WriteString(schemas.Modeline(rt, schemas.TapConfig)) b.WriteString("# Project config. Uncomment and edit fields below to override the user\n") b.WriteString("# config for this directory tree. While everything stays commented this\n") b.WriteString("# file is inert. Note: hubs and tokens may only live in the user config\n") @@ -1281,10 +1157,17 @@ func (cfg *Config) ToYAML() ([]byte, error) { if cfg.data == nil { cfg.data = &configDTO{} } - body, err := yaml.Marshal(cfg.data) + doc, err := overlayConfigDocument(cfg.doc, cfg.data) + if err != nil { + return nil, err + } + body, err := yaml.Marshal(doc) if err != nil { return nil, err } + if bytes.HasPrefix(body, []byte(schemas.ModelinePrefix)) { + return body, nil + } return append([]byte(tapConfigSchemaModeline), body...), nil } @@ -1294,6 +1177,9 @@ func (cfg *Config) Write(rt *toolkit.Runtime, path string) error { if err != nil { return fmt.Errorf("unable to write user config: %w", err) } + // Point the modeline at the schema copy materialized from this binary so + // an editor completes against the shape this build actually accepts. + data = schemas.ReplaceModeline(data, schemas.Modeline(rt, schemas.TapConfig)) if err := rt.AtomicWriteFile(path, data, 0o644); err != nil { return fmt.Errorf("unable to write config: %w", err) @@ -1366,9 +1252,6 @@ func MergeConfig(cfgs ...*Config) *Config { if c.data.DisableAtlasHub { out.data.DisableAtlasHub = true } - if c.data.DisableLocalHub { - out.data.DisableLocalHub = true - } if c.data.DisableTelemetry { out.data.DisableTelemetry = true } diff --git a/pkg/tapper/config_agent_flight_test.go b/pkg/tapper/config_agent_flight_test.go index 19428fc2..7264d999 100644 --- a/pkg/tapper/config_agent_flight_test.go +++ b/pkg/tapper/config_agent_flight_test.go @@ -5,6 +5,7 @@ import ( "github.com/jlrickert/cli-toolkit/sandbox" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/jlrickert/tapper/pkg/tapper" ) @@ -42,20 +43,18 @@ func newAgentFlightTap(t *testing.T, projectConfig string, env map[string]string return tap, sb } -func TestAgentFlight_TapAgentSelectsTheAgentsFlight(t *testing.T) { +func TestAgentFlight_TapAgentDoesNotSelectAFlight(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "", map[string]string{"TAP_AGENT": "qwen"}) cfg, err := tap.ConfigService.Config() require.NoError(t, err) require.Equal(t, "qwen", cfg.AgentName()) - require.Equal(t, "+test", cfg.Flight(), - "the agent's flight must win over the user baseline") + require.Equal(t, "+user", cfg.Flight(), + "TAP_AGENT is model selection and telemetry only") } -// TAP_FLIGHT is a direct value and the agent only a reference to one, so the -// direct value wins. This is the escape hatch that lets a human override a -// launched session without editing config. +// TAP_FLIGHT is the direct immutable launch-root reference. func TestAgentFlight_TapFlightOutranksTheAgent(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "", map[string]string{ @@ -68,15 +67,13 @@ func TestAgentFlight_TapFlightOutranksTheAgent(t *testing.T) { require.Equal(t, "+debug", cfg.Flight()) } -// Naming an agent at launch is deliberate, so it outranks an ambient project -// default. This preserves what `tap launch` did when it exported TAP_FLIGHT. -func TestAgentFlight_AgentOutranksProjectConfig(t *testing.T) { +func TestAgentFlight_ProjectFlightIsIndependentOfAgent(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "flight: +proj\n", map[string]string{"TAP_AGENT": "qwen"}) cfg, err := tap.ConfigService.Config() require.NoError(t, err) - require.Equal(t, "+test", cfg.Flight()) + require.Equal(t, "+proj", cfg.Flight()) } func TestAgentFlight_NoAgentLeavesTheCascadeAlone(t *testing.T) { @@ -89,8 +86,7 @@ func TestAgentFlight_NoAgentLeavesTheCascadeAlone(t *testing.T) { require.Empty(t, cfg.AgentName()) } -// An agent with no flight contributes nothing rather than clearing the -// selection, matching a launch that had no flight to export. +// An agent's legacy flight field is ignored whether it is present or absent. func TestAgentFlight_AgentWithoutFlightFallsThrough(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "flight: +proj\n", map[string]string{"TAP_AGENT": "flightless"}) @@ -101,9 +97,7 @@ func TestAgentFlight_AgentWithoutFlightFallsThrough(t *testing.T) { require.Empty(t, warnings, "an agent may legitimately carry no flight") } -// A stale TAP_AGENT is reported, not fatal: the session cannot fix its own -// environment, and failing hard would brick a harness over a typo. -func TestAgentFlight_UnknownAgentWarnsAndFallsThrough(t *testing.T) { +func TestAgentFlight_UnknownAgentDoesNotAffectFlight(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "flight: +proj\n", map[string]string{"TAP_AGENT": "ghost"}) @@ -111,21 +105,16 @@ func TestAgentFlight_UnknownAgentWarnsAndFallsThrough(t *testing.T) { require.NoError(t, err) require.Equal(t, "+proj", cfg.Flight()) - require.Len(t, warnings, 1) - require.Equal(t, "agent", warnings[0].Source) - require.Contains(t, warnings[0].Message, `"ghost"`) + require.Empty(t, warnings) } -// The regression this whole mechanism exists for: a running process must see an -// edited agent flight after a reload. Exporting a resolved TAP_FLIGHT could not -// do this, because a process cannot change its own environment. -func TestAgentFlight_ReloadPicksUpAnEditedAgentFlight(t *testing.T) { +func TestAgentFlight_ReloadDoesNotAdoptEditedAgentFlight(t *testing.T) { t.Parallel() tap, sb := newAgentFlightTap(t, "", map[string]string{"TAP_AGENT": "qwen"}) cfg, err := tap.ConfigService.Config() require.NoError(t, err) - require.Equal(t, "+test", cfg.Flight()) + require.Equal(t, "+user", cfg.Flight()) require.NoError(t, sb.Runtime().AtomicWriteFile( "/home/testuser/.config/tapper/config.yaml", @@ -140,23 +129,44 @@ agents: // Still the old value: configuration is fixed until something reloads. cfg, err = tap.ConfigService.Config() require.NoError(t, err) - require.Equal(t, "+test", cfg.Flight()) + require.Equal(t, "+user", cfg.Flight()) tap.ConfigService.Reload() cfg, err = tap.ConfigService.Config() require.NoError(t, err) - require.Equal(t, "+admin", cfg.Flight(), - "a reload must re-resolve the agent's flight, not reuse the launch-time value") + require.Equal(t, "+user", cfg.Flight(), + "reorientation refreshes the root manifest, not the root selection") } -func TestAgentFlight_ExplainCreditsTheAgent(t *testing.T) { +func TestAgentFlight_ExplainCreditsTheProject(t *testing.T) { t.Parallel() tap, _ := newAgentFlightTap(t, "flight: +proj\n", map[string]string{"TAP_AGENT": "qwen"}) results, err := tap.ConfigExplain(t.Context(), tapper.ConfigExplainOptions{Field: "flight"}) require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, "+test", results[0].Value) - require.Equal(t, `agent "qwen"`, results[0].Source, - "explain must name the agent rather than the project config it overrode") + require.Equal(t, "+proj", results[0].Value) + require.Equal(t, "project config", results[0].Source) +} + +func TestAgentFlight_LegacyFieldIsIgnoredAndPreserved(t *testing.T) { + t.Parallel() + + cfg, err := tapper.ParseConfig([]byte("flight: +top-level\n" + + "agents:\n" + + " qwen:\n" + + " model: ollama/qwen3.6:35b\n" + + " flight: +legacy-agent\n")) + require.NoError(t, err) + require.Equal(t, "+top-level", cfg.Flight()) + require.NoError(t, cfg.SetFlight("+rewritten-top-level")) + + out, err := cfg.ToYAML() + require.NoError(t, err) + var doc map[string]any + require.NoError(t, yaml.Unmarshal(out, &doc)) + agents := doc["agents"].(map[string]any) + qwen := agents["qwen"].(map[string]any) + require.Equal(t, "+legacy-agent", qwen["flight"]) + require.Equal(t, "+rewritten-top-level", doc["flight"]) } diff --git a/pkg/tapper/config_document.go b/pkg/tapper/config_document.go new file mode 100644 index 00000000..46cca914 --- /dev/null +++ b/pkg/tapper/config_document.go @@ -0,0 +1,184 @@ +package tapper + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +var configOwnedFields = map[string]struct{}{ + "logFile": {}, "logLevel": {}, "updated": {}, "defaultKeg": {}, + "fallbackKeg": {}, "flight": {}, "agent": {}, "kegMap": {}, + "namespaces": {}, "defaultHub": {}, "fallbackHub": {}, + "defaultNamespace": {}, "fallbackNamespace": {}, "disableAtlasHub": {}, + "disableTelemetry": {}, "hubs": {}, "agents": {}, +} + +var configObjectOwnedFields = map[string]map[string]struct{}{ + "hubs": { + "kind": {}, "defaultNamespace": {}, "url": {}, "token": {}, "tokenEnv": {}, + }, + "namespaces": {"hub": {}}, + "agents": { + "model": {}, "baseUrl": {}, "auth": {}, "apiKeyEnv": {}, + "contextWindow": {}, "args": {}, + }, +} + +var kegMapOwnedFields = map[string]struct{}{ + "alias": {}, "pathPrefix": {}, "pathRegex": {}, +} + +func overlayConfigDocument(original *yaml.Node, data *configDTO) (*yaml.Node, error) { + typedRaw, err := yaml.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal typed config: %w", err) + } + var typed yaml.Node + if err := yaml.Unmarshal(typedRaw, &typed); err != nil { + return nil, fmt.Errorf("decode typed config document: %w", err) + } + if original == nil || mappingNode(original) == nil { + return cloneYAMLNode(&typed), nil + } + + out := cloneYAMLNode(original) + dst := mappingNode(out) + src := mappingNode(&typed) + for field := range configOwnedFields { + srcValue, present := mappingValue(src, field) + if !present { + removeMappingValue(dst, field) + continue + } + dstValue, exists := mappingValue(dst, field) + switch field { + case "hubs", "namespaces", "agents": + if exists && dstValue.Kind == yaml.MappingNode && srcValue.Kind == yaml.MappingNode { + overlayNamedObjects(dstValue, srcValue, configObjectOwnedFields[field]) + } else { + setMappingValue(dst, field, cloneYAMLNode(srcValue)) + } + case "kegMap": + if exists && dstValue.Kind == yaml.SequenceNode && srcValue.Kind == yaml.SequenceNode { + overlaySequenceObjects(dstValue, srcValue, kegMapOwnedFields) + } else { + setMappingValue(dst, field, cloneYAMLNode(srcValue)) + } + default: + setMappingValue(dst, field, cloneYAMLNode(srcValue)) + } + } + return out, nil +} + +func overlayNamedObjects(dst, src *yaml.Node, owned map[string]struct{}) { + wanted := make(map[string]*yaml.Node, len(src.Content)/2) + for i := 0; i+1 < len(src.Content); i += 2 { + wanted[src.Content[i].Value] = src.Content[i+1] + } + for i := len(dst.Content) - 2; i >= 0; i -= 2 { + if _, ok := wanted[dst.Content[i].Value]; !ok { + dst.Content = append(dst.Content[:i], dst.Content[i+2:]...) + } + } + for name, srcValue := range wanted { + dstValue, ok := mappingValue(dst, name) + if ok && dstValue.Kind == yaml.MappingNode && srcValue.Kind == yaml.MappingNode { + overlayOwnedMapping(dstValue, srcValue, owned) + continue + } + setMappingValue(dst, name, cloneYAMLNode(srcValue)) + } +} + +func overlaySequenceObjects(dst, src *yaml.Node, owned map[string]struct{}) { + common := len(dst.Content) + if len(src.Content) < common { + common = len(src.Content) + } + for i := 0; i < common; i++ { + if dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode { + overlayOwnedMapping(dst.Content[i], src.Content[i], owned) + } else { + dst.Content[i] = cloneYAMLNode(src.Content[i]) + } + } + if len(dst.Content) > len(src.Content) { + dst.Content = dst.Content[:len(src.Content)] + } + for i := common; i < len(src.Content); i++ { + dst.Content = append(dst.Content, cloneYAMLNode(src.Content[i])) + } +} + +func overlayOwnedMapping(dst, src *yaml.Node, owned map[string]struct{}) { + for field := range owned { + value, ok := mappingValue(src, field) + if !ok { + removeMappingValue(dst, field) + continue + } + setMappingValue(dst, field, cloneYAMLNode(value)) + } +} + +func mappingNode(doc *yaml.Node) *yaml.Node { + if doc == nil { + return nil + } + if doc.Kind == yaml.DocumentNode { + if len(doc.Content) == 0 { + return nil + } + return doc.Content[0] + } + if doc.Kind == yaml.MappingNode { + return doc + } + return nil +} + +func mappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil, false + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1], true + } + } + return nil, false +} + +func setMappingValue(mapping *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1] = value + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value) +} + +func removeMappingValue(mapping *yaml.Node, key string) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + return + } + } +} + +func cloneYAMLNode(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + clone := *node + clone.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + clone.Content[i] = cloneYAMLNode(child) + } + return &clone +} diff --git a/pkg/tapper/config_env.go b/pkg/tapper/config_env.go index 58cfd4ef..9812ae73 100644 --- a/pkg/tapper/config_env.go +++ b/pkg/tapper/config_env.go @@ -18,7 +18,6 @@ var tapEnvVarKeys = []string{ "DEFAULT_NAMESPACE", "FALLBACK_NAMESPACE", "DISABLE_ATLAS_HUB", - "DISABLE_LOCAL_HUB", "DISABLE_TELEMETRY", } @@ -66,9 +65,6 @@ func configFromEnvMap(envMap map[string]string) *Config { if v, ok := envMap["disable_atlas_hub"]; ok { cfg.data.DisableAtlasHub = parseEnvBool(v) } - if v, ok := envMap["disable_local_hub"]; ok { - cfg.data.DisableLocalHub = parseEnvBool(v) - } if v, ok := envMap["disable_telemetry"]; ok { cfg.data.DisableTelemetry = parseEnvBool(v) } diff --git a/pkg/tapper/config_env_test.go b/pkg/tapper/config_env_test.go index 1156c670..a9829a06 100644 --- a/pkg/tapper/config_env_test.go +++ b/pkg/tapper/config_env_test.go @@ -221,28 +221,6 @@ func TestConfigService_DisableAtlasHubViaEnv(t *testing.T) { }) } -// TestConfigService_DisableLocalHubViaEnv mirrors the atlas test for -// TAP_DISABLE_LOCAL_HUB: a truthy env value flips DisableLocalHub. -func TestConfigService_DisableLocalHubViaEnv(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t, sandbox.WithFixture("basic", "/home/testuser")) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - require.NoError(t, fx.Runtime().Env().Set("TAP_DISABLE_LOCAL_HUB", "true")) - - cfg, err := tap.ConfigService.Config() - require.NoError(t, err) - require.True(t, cfg.DisableLocalHub(), - "TAP_DISABLE_LOCAL_HUB=true should set DisableLocalHub") -} - func TestConfigService_EnvOverrideWithStrict(t *testing.T) { t.Parallel() diff --git a/pkg/tapper/config_service.go b/pkg/tapper/config_service.go index 3565ba5c..e213aba4 100644 --- a/pkg/tapper/config_service.go +++ b/pkg/tapper/config_service.go @@ -15,9 +15,7 @@ import ( // ErrNotBootstrapped is returned by hub/namespace-dependent operations on the // full `tap` surface when no user config exists yet — i.e. `tap bootstrap` has -// not been run. Explicit filesystem destinations (--path/--project/--cwd) and -// the pruned `keg` binary are exempt, and callers that resolve a keg by an -// explicit filesystem path are too. +// not been run. var ErrNotBootstrapped = errors.New("tapper is not set up on this machine; run `tap bootstrap` to get started") // ConfigLoadWarning represents a non-fatal issue encountered while loading config. @@ -32,13 +30,15 @@ type ConfigLoadWarning struct { // // Configuration is read once and then fixed for the life of the process. A // `tap` command therefore runs against one consistent snapshot, and a -// long-lived `tap mcp` session picks up an external edit at its next orient, -// which is the one place Reload is called. Nothing inside a session can write +// long-lived `tap mcp` session reloads transport configuration while resolving +// every authority-bearing call. Nothing inside a session can write // configuration — the `config` tool is read-only — so "edit the file, then // reorient" is the whole update story. // // The snapshot is immutable once published, so concurrent readers need no -// coordination beyond the mutex guarding the pointer itself. That matters +// coordination beyond the mutex guarding the pointer itself. The pinned flight +// root remains immutable even though Hub routing and live authority reload. +// That matters // because the MCP SDK dispatches every call except initialize asynchronously. // // Flight authority is not affected by a reload: the MCP session gate snapshots @@ -67,12 +67,7 @@ type resolved struct { userErr error project *Config projectErr error - // env is the env-var layer in isolation. The merged config cannot answer - // "did TAP_FLIGHT set this?", and agent resolution has to know: a direct - // TAP_FLIGHT outranks the flight an agent points at, while a flight coming - // from a file layer does not. - env *Config - warnings []ConfigLoadWarning + warnings []ConfigLoadWarning } // NewConfigService builds a ConfigService rooted at root. @@ -335,7 +330,6 @@ func (s *ConfigService) load() (*resolved, error) { return nil, err } cfg := configFromEnvMap(envMap) - out.env = cfg if cfg == nil { return nil, os.ErrNotExist } @@ -376,57 +370,9 @@ func (s *ConfigService) load() (*resolved, error) { if out.merged == nil { out.merged = &Config{data: &configDTO{}} } - if warning := applyAgentFlight(out.merged, out.env); warning != nil { - out.warnings = append(out.warnings, *warning) - } return out, nil } -// applyAgentFlight resolves the active agent's flight into merged, and is why -// `tap launch` can export an agent name instead of a resolved flight. The agent -// is a reference, so the lookup happens on every load; a flight baked into the -// environment at launch would instead be frozen for the life of the process and -// no amount of reloading could move it. -// -// It sits between the env and project layers of the cascade rather than inside -// it, because the cascade merges whole Configs by rank and this rule needs two -// layers at once: the agents map comes from the file layers, while the decision -// to apply it at all depends on the env layer. Running here also means every -// consumer of ConfigService.Config sees one already-resolved flight. -// -// A returned warning means the selection named an agent that is not configured. -// That is reported rather than fatal: the session is still usable on whatever -// the file layers select, and a hard failure over a stale TAP_AGENT would brick -// a harness for a typo it cannot fix from the inside. -func applyAgentFlight(merged, env *Config) *ConfigLoadWarning { - if merged == nil { - return nil - } - name := merged.AgentName() - if name == "" { - return nil - } - // A direct TAP_FLIGHT outranks the agent's indirect one, so leave it be. - if env != nil && strings.TrimSpace(env.Flight()) != "" { - return nil - } - entry, ok := merged.Agent(name) - if !ok { - return &ConfigLoadWarning{ - Source: "agent", - Message: fmt.Sprintf( - "agent %q is selected but not configured, so its flight could not be applied; "+ - "the flight falls back to project and user configuration", name), - } - } - // An agent without a flight selects no flight, matching a launch that had - // none to export. - if flight := strings.TrimSpace(entry.Flight); flight != "" { - _ = merged.SetFlight(flight) - } - return nil -} - // ResolveTarget resolves a keg selector to a keg target. When the selector is // empty it uses defaultKeg, then fallbackKeg. The selector is parsed as a keg // reference and turned into a concrete target by Config.ResolveAlias (the @@ -446,6 +392,16 @@ func (s *ConfigService) ResolveTarget(alias, nsOverride, hubOverride string) (*k if requestedAlias == "" { return nil, fmt.Errorf("no keg configured (set defaultKeg/fallbackKeg or use --keg)") } + if target, parseErr := keg.Parse(requestedAlias); parseErr == nil && + (target.Scheme() == keg.SchemeHTTP || target.Scheme() == keg.SchemeHTTPs) { + if strings.TrimSpace(nsOverride) != "" || strings.TrimSpace(hubOverride) != "" { + return nil, fmt.Errorf("--namespace and --hub cannot be combined with an HTTP(S) KEG endpoint") + } + return target, nil + } else if strings.HasPrefix(requestedAlias, "/") || strings.HasPrefix(requestedAlias, "~") || + strings.HasPrefix(requestedAlias, ".") || strings.HasPrefix(requestedAlias, "file://") { + return nil, parseErr + } // Apply the --namespace / --hub overrides onto the parsed reference. ref, err := applyRefOverrides(parseKegRef(requestedAlias), nsOverride, hubOverride, requestedAlias) diff --git a/pkg/tapper/config_test.go b/pkg/tapper/config_test.go index 12b51dc4..e988e579 100644 --- a/pkg/tapper/config_test.go +++ b/pkg/tapper/config_test.go @@ -8,9 +8,10 @@ import ( "github.com/jlrickert/tapper/pkg/tapper" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) -func TestWriteUserConfig_NormalizesWithoutComments(t *testing.T) { +func TestWriteUserConfigPreservesCommentsAndUnknownBlocks(t *testing.T) { t.Parallel() raw := `# Top comment @@ -32,9 +33,9 @@ kegMap: require.NoError(t, err, "ToYAML failed") out := string(data) - // Comment preservation is no longer required. - require.NotContains(t, out, "# Top comment") - require.NotContains(t, out, "# inline url comment") + require.Contains(t, out, "# Top comment") + require.Contains(t, out, "# inline url comment") + require.Contains(t, out, "kegs:") require.Contains(t, out, "defaultKeg: main") require.Contains(t, out, "pathPrefix: ~/projects") } @@ -61,8 +62,7 @@ kegMap: require.Contains(t, out, "defaultKeg: main") require.Contains(t, out, "pathPrefix: ~/projects") - require.NotContains(t, out, "# config header") - require.NotContains(t, out, "# keep this inline") + require.Contains(t, out, "# config header") } func TestParseConfig_AcceptsUnknownFields(t *testing.T) { @@ -75,14 +75,104 @@ unknownKey: value cfg, err := tapper.ParseConfig([]byte(raw)) require.NoError(t, err) require.Equal(t, "main", cfg.DefaultKeg()) + out, err := cfg.ToYAML() + require.NoError(t, err) + require.Contains(t, string(out), "unknownKey: value") +} + +func TestConfigRewritePreservesUnknownTopLevelAndNestedFields(t *testing.T) { + t.Parallel() + + raw := `defaultKeg: old +vendorFeature: + enabled: true +hubs: + work: + kind: remote + url: https://old.example.com + tokenEnv: WORK_TOKEN + retryPolicy: + attempts: 7 +namespaces: + team: + hub: work + tenantId: tenant-42 +agents: + builder: + model: openai/gpt-5 + providerOption: retained +kegMap: + - alias: "@team/notes" + pathPrefix: /workspace + extensionRule: retained +` + cfg, err := tapper.ParseConfig([]byte(raw)) + require.NoError(t, err) + require.NoError(t, cfg.SetDefaultKeg("@team/new-default")) + require.NoError(t, cfg.SetHub("work", tapper.HubEntry{ + Kind: "readonly", + URL: "https://new.example.com", + })) + require.NoError(t, cfg.SetNamespace("team", tapper.NamespaceRef{Hub: "cloud"})) + + out, err := cfg.ToYAML() + require.NoError(t, err) + var doc map[string]any + require.NoError(t, yaml.Unmarshal(out, &doc)) + require.Equal(t, "@team/new-default", doc["defaultKeg"]) + require.Equal(t, map[string]any{"enabled": true}, doc["vendorFeature"]) + + hubs := doc["hubs"].(map[string]any) + work := hubs["work"].(map[string]any) + require.Equal(t, "readonly", work["kind"]) + require.Equal(t, "https://new.example.com", work["url"]) + require.NotContains(t, work, "tokenEnv") + require.Equal(t, map[string]any{"attempts": 7}, work["retryPolicy"]) + + namespaces := doc["namespaces"].(map[string]any) + team := namespaces["team"].(map[string]any) + require.Equal(t, "cloud", team["hub"]) + require.Equal(t, "tenant-42", team["tenantId"]) + + agents := doc["agents"].(map[string]any) + builder := agents["builder"].(map[string]any) + require.Equal(t, "retained", builder["providerOption"]) + kegMap := doc["kegMap"].([]any) + require.Equal(t, "retained", kegMap[0].(map[string]any)["extensionRule"]) } -func TestParseUserConfig_IgnoresUnknownKeys(t *testing.T) { +func TestConfigExplicitObjectRemovalRemovesUnknownNestedFields(t *testing.T) { t.Parallel() - // The config decoder ignores keys it does not recognize (here a `kegs` - // block, which the schema does not define). Parsing succeeds and the - // unknown block does not round-trip on re-serialize. + cfg, err := tapper.ParseConfig([]byte(`hubs: + keep: {url: https://keep.example.com, vendor: keep} + remove: {url: https://remove.example.com, vendor: remove} +namespaces: + keep: {hub: keep, vendor: keep} + remove: {hub: remove, vendor: remove} +`)) + require.NoError(t, err) + removed, err := cfg.DeleteHub("remove") + require.NoError(t, err) + require.True(t, removed) + require.True(t, cfg.DeleteNamespace("remove")) + + out, err := cfg.ToYAML() + require.NoError(t, err) + var doc map[string]any + require.NoError(t, yaml.Unmarshal(out, &doc)) + hubs := doc["hubs"].(map[string]any) + require.NotContains(t, hubs, "remove") + require.Equal(t, "keep", hubs["keep"].(map[string]any)["vendor"]) + namespaces := doc["namespaces"].(map[string]any) + require.NotContains(t, namespaces, "remove") + require.Equal(t, "keep", namespaces["keep"].(map[string]any)["vendor"]) +} + +func TestParseUserConfigPreservesUnknownKeys(t *testing.T) { + t.Parallel() + + // Unknown blocks load and survive Tapper-driven serialization. raw := `defaultKeg: notes fallbackNamespace: alice kegs: @@ -91,12 +181,13 @@ kegs: ` uc, err := tapper.ParseConfig([]byte(raw)) - require.NoError(t, err, "ParseConfig must ignore unknown keys") + require.NoError(t, err) require.Equal(t, "notes", uc.DefaultKeg()) data, err := uc.ToYAML() require.NoError(t, err) - require.NotContains(t, string(data), "kegs:", "the unknown kegs block must not round-trip") + require.Contains(t, string(data), "kegs:") + require.Contains(t, string(data), "short: \"keg:@bob/blog\"") } func TestResolveAlias_Behavior(t *testing.T) { @@ -255,30 +346,6 @@ namespaces: require.Equal(t, "cloud", kt.Hub) require.Equal(t, "lone", kt.Namespace) - // The reserved @local namespace pins this machine's filesystem hub. - kt, err = uc.ResolveRef(fx.Runtime(), tapper.KegRef{Namespace: tapper.LocalHubName, Name: "notes"}) - require.NoError(t, err) - require.Contains(t, kt.String(), filepath.Join("@local", "notes")) -} - -func TestResolveRef_LocalLayout(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - raw := `hubs: - home: - kind: local - defaultNamespace: local - basePath: ` + filepath.Join(fx.GetJail(), "data", "kegs") + ` -` - uc, err := tapper.ParseConfig([]byte(raw)) - require.NoError(t, err) - - // A bare name resolves through the sole local hub (namespace "local"). - kt, err := uc.ResolveAlias(fx.Runtime(), "notes") - require.NoError(t, err) - // Local kegs live under /@/. - require.Contains(t, kt.String(), filepath.Join("@local", "notes")) } func TestResolveProjectKeg_PrefixAndRegexPrecedence(t *testing.T) { @@ -409,7 +476,7 @@ func TestAddKegMap_AddsAndUpdatesEntries(t *testing.T) { func TestAddKegMap_ReturnsErrorOnNilOrEmptyAlias(t *testing.T) { t.Parallel() - cfg := tapper.DefaultUserConfig("testuser", "/tmp") + cfg := tapper.DefaultUserConfig("testuser") // Test nil config var nilCfg *tapper.Config @@ -472,12 +539,10 @@ func TestMergeConfig_PreservesMultipleEntriesWithSameAlias(t *testing.T) { require.Len(t, kegMap, 2, "both work entries should survive merge") } -func TestParseConfig_UnknownKeysIgnored(t *testing.T) { +func TestParseConfigUnknownKeysSurviveRewrite(t *testing.T) { t.Parallel() - // The decoder ignores keys it does not recognize (e.g. kegSearchPaths, - // userRepoPath, kegs). Parsing succeeds and those keys are dropped on - // re-serialization. + // Arbitrary unknown keys remain semantically present on re-serialization. raw := `fallbackKeg: pub kegSearchPaths: - ~/Documents/kegs @@ -492,7 +557,9 @@ kegs: {} out, err := cfg.ToYAML() require.NoError(t, err) - require.NotContains(t, string(out), "kegSearchPaths") + require.Contains(t, string(out), "kegSearchPaths") + require.Contains(t, string(out), "userRepoPath") + require.Contains(t, string(out), "kegs: {}") } func TestMergeConfig_DefaultFallbackPrecedence(t *testing.T) { @@ -531,7 +598,7 @@ kegs: {} func TestConfigToYAML_PrependsSchemaModeline(t *testing.T) { t.Parallel() - cfg := tapper.DefaultUserConfig("pub", "~/Documents/kegs") + cfg := tapper.DefaultUserConfig("pub") out, err := cfg.ToYAML() require.NoError(t, err) require.True(t, strings.HasPrefix(string(out), "# yaml-language-server: $schema="+tapper.TapConfigSchemaURL+"\n")) diff --git a/pkg/tapper/constants.go b/pkg/tapper/constants.go index bf5f521a..fea1e7ab 100644 --- a/pkg/tapper/constants.go +++ b/pkg/tapper/constants.go @@ -1,6 +1,6 @@ package tapper -// Config version strings identify KEG configuration schema versions. Each +// Config version strings identify KEG settings schema versions. Each // constant is a stable identifier for a particular config schema. When a new // schema is introduced add a new constant and update the Config alias to // point to the latest version. These values are used by parsing and migration diff --git a/pkg/tapper/error_types.go b/pkg/tapper/error_types.go deleted file mode 100644 index 95cb1789..00000000 --- a/pkg/tapper/error_types.go +++ /dev/null @@ -1,56 +0,0 @@ -package tapper - -import ( - "fmt" - "strings" -) - -// ProjectKegNotFoundError indicates project-local keg discovery failed. -// Tried contains the concrete keg-file locations that were checked. -type ProjectKegNotFoundError struct { - Tried []string -} - -func (e *ProjectKegNotFoundError) Error() string { - if e == nil { - return "project keg not found" - } - switch len(e.Tried) { - case 0: - return "project keg not found" - case 1: - return fmt.Sprintf("project keg not found; expected a `keg` file at %s", e.Tried[0]) - default: - return fmt.Sprintf("project keg not found; expected a `keg` file at %s or %s", e.Tried[0], e.Tried[1]) - } -} - -// UserMessage returns a CLI-context-aware message. When debug is true and -// search paths are available, they are included in the output. -func (e *ProjectKegNotFoundError) UserMessage(debug bool) string { - if debug && len(e.Tried) > 0 { - return fmt.Sprintf("project keg not found in this project (searched: %s)", strings.Join(e.Tried, ", ")) - } - return "project keg not found in this project" -} - -func newProjectKegNotFoundError(paths []string) error { - cleaned := make([]string, 0, len(paths)) - for _, p := range paths { - p = strings.TrimSpace(p) - if p == "" { - continue - } - cleaned = append(cleaned, p) - } - return &ProjectKegNotFoundError{Tried: cleaned} -} - -// PathNotFoundError indicates that the explicit --path target does not exist on disk. -type PathNotFoundError struct { - Path string -} - -func (e *PathNotFoundError) Error() string { - return fmt.Sprintf("keg not found at path %q: directory does not exist", e.Path) -} diff --git a/pkg/tapper/flight.go b/pkg/tapper/flight.go index ae9b2b13..42c1a6a9 100644 --- a/pkg/tapper/flight.go +++ b/pkg/tapper/flight.go @@ -6,26 +6,20 @@ import ( "encoding/json" "errors" "fmt" - "path/filepath" "sort" "strings" "sync" "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" - "gopkg.in/yaml.v3" + "github.com/jlrickert/tapper/pkg/schemas" ) -// flightsDirName is the reserved directory — a sibling of the @ dirs -// of a local hub — that holds flight manifests. "flights.d" is an invalid -// namespace (it contains a dot), so it can never collide with a keg path. const ( - flightsDirName = "flights.d" - - // FlightManifestSchemaURL is the public JSON Schema used by editor - // modelines for flight manifest YAML. - FlightManifestSchemaURL = "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/flight-manifest.json" - flightManifestSchemaModeline = "# yaml-language-server: $schema=" + FlightManifestSchemaURL + "\n" + // FlightManifestSchemaURL is the published JSON Schema for flight manifest + // YAML. Editor modelines prefer the local copy materialized by pkg/schemas + // and fall back to this. + FlightManifestSchemaURL = schemas.FlightManifestURL ) type FlightRole string @@ -44,43 +38,20 @@ const ( FlightCapabilityManageKegs FlightCapability = "manage_kegs" FlightCapabilityFullAccess FlightCapability = "full_access" - // BootstrapFlightSlug names the synthetic flight a session runs on when no - // flight exists to select. It is never persisted, so the slug only has to be - // recognizable in orientation output and unambiguous against a real ref. - BootstrapFlightSlug = "bootstrap" + // MaxFlightSubflights bounds the ordered direct allowlist on one manifest. + MaxFlightSubflights = 64 + // MaxFlightGraphDescendants bounds unique reachable flights, excluding the + // pinned root. Shared descendants count once. This is the only bound on + // traversal: it is enforced inline during the breadth-first walk and is + // dedup-based, so it holds regardless of the graph's shape. + MaxFlightGraphDescendants = 256 ) -// BootstrapFlight returns the synthetic flight used when an identity can reach -// no flights at all. Its cover is empty, so every KEG operation is still -// denied; what it grants is the authority to create the first flight and the -// first keg. instructions carries the transport's own recovery text — the local -// and hosted surfaces nudge toward different places — and rides in the manifest -// rather than through BuildOrientationPayload's flightNote because a flight's -// instructions are already rendered and are inherently per-flight. -func BootstrapFlight(namespace, instructions string) *Flight { - ref := FlightRef{Namespace: strings.TrimPrefix(strings.TrimSpace(namespace), "@"), Slug: BootstrapFlightSlug} - // No Title: BuildOrientationPayload writes a flight's title verbatim, and a - // bare "Bootstrap" line adds nothing next to the paragraph it already emits - // for this mode. - m := FlightManifest{ - Visibility: FlightVisibilityPrivate, - Capabilities: []FlightCapability{ - FlightCapabilityManageFlights, - FlightCapabilityManageKegs, - }, - Instructions: instructions, - } - normalizeFlightManifest(&m) - return &Flight{ - Name: ref.Canonical(), - Namespace: ref.Namespace, - Slug: ref.Slug, - Source: "synthetic", - Bootstrap: true, - ManifestHash: hashFlightManifest(m), - FlightManifest: m, - } -} +// ErrFlightSubflightNotAllowed marks a selection outside the immutable root's +// live, identity-accessible transitive graph. The historical name is retained +// for callers that classify this authority denial; selection is no longer +// limited to direct children. +var ErrFlightSubflightNotAllowed = errors.New("flight is not available from the pinned root") // AtLeast reports whether r grants at least want within a flight cover. func (r FlightRole) AtLeast(want FlightRole) bool { @@ -115,29 +86,25 @@ type FlightCover struct { Role FlightRole `yaml:"role" json:"role"` } -// FlightManifest is the on-disk/API shape of a flight: explicit covered kegs -// plus markdown instructions. AllowedKegs is kept for backward compatibility -// with local manifests and is normalized into editor-cap cover entries. +// FlightManifest is the Hub API shape of a flight: explicit covered kegs plus +// markdown instructions. AllowedKegs remains a legacy wire field and is +// normalized into editor-cap cover entries. type FlightManifest struct { Title string `yaml:"title,omitempty" json:"title,omitempty"` Visibility string `yaml:"visibility,omitempty" json:"visibility,omitempty"` Capabilities []FlightCapability `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` Cover []FlightCover `yaml:"cover,omitempty" json:"cover,omitempty"` + Subflights []string `yaml:"subflights,omitempty" json:"subflights,omitempty"` AllowedKegs []string `yaml:"allowedKegs,omitempty" json:"allowedKegs,omitempty"` Instructions string `yaml:"instructions,omitempty" json:"instructions,omitempty"` } // Flight is a discovered flight: its manifest plus provenance. type Flight struct { - Name string `yaml:"-" json:"name,omitempty"` - Namespace string `yaml:"-" json:"namespace,omitempty"` - Slug string `yaml:"-" json:"slug,omitempty"` - Source string `yaml:"-" json:"source,omitempty"` // "local" or a hub name - // Bootstrap marks the synthetic flight from BootstrapFlight. It is a field - // rather than a Source sentinel because Source is provenance an operator - // reads, and a caller asking "is this real?" should not have to know which - // string means synthetic. - Bootstrap bool `yaml:"-" json:"bootstrap,omitempty"` + Name string `yaml:"-" json:"name,omitempty"` + Namespace string `yaml:"-" json:"namespace,omitempty"` + Slug string `yaml:"-" json:"slug,omitempty"` + Source string `yaml:"-" json:"source,omitempty"` // configured hub name ManifestHash string `yaml:"-" json:"-"` FlightManifest } @@ -189,9 +156,7 @@ func (r FlightRef) Canonical() string { return "@" + r.Namespace + "/+" + r.Slug } -// FlightService discovers and loads flights for configured local and remote -// hubs. Local-hub flights live under /flights.d; remote-hub flights -// are served by the Hub API. +// FlightService discovers and loads flights from configured remote hubs. type FlightService struct { Runtime *toolkit.Runtime ConfigService *ConfigService @@ -255,24 +220,6 @@ func (s *FlightService) config() (*Config, error) { return cfg, nil } -// localFlightsDirFor returns /flights.d for a local hub entry, -// resolving the basePath the same way Config.ResolveRef does. -func (s *FlightService) localFlightsDirFor(entry HubEntry) (string, error) { - base := strings.TrimSpace(entry.BasePath) - if base == "" { - root, rootErr := defaultUserKegRoot(s.Runtime) - if rootErr != nil { - return "", rootErr - } - base = root - } - base = toolkit.ExpandEnv(s.Runtime, base) - if expanded, expErr := toolkit.ExpandPath(s.Runtime, base); expErr == nil { - base = expanded - } - return filepath.Join(base, flightsDirName), nil -} - // ListFlights returns canonical @namespace/+slug refs for flights discovered // across configured hubs, sorted. When hub is non-empty, discovery is limited // to that configured hub. Remote/auth/network errors are best-effort so shell @@ -316,14 +263,6 @@ func (s *FlightService) ListFlights(ctx context.Context, hub string, warnings *[ kind = HubKindRemote } switch kind { - case HubKindLocal: - dir, dirErr := s.localFlightsDirFor(entry) - if dirErr != nil { - continue - } - for _, ref := range s.listLocalFlights(dir, localFlightNamespace(entry)) { - add(ref) - } case HubKindRemote, HubKindReadonly: flights, listErr := s.listRemoteFlights(ctx, name, entry) if listErr != nil { @@ -335,39 +274,19 @@ func (s *FlightService) ListFlights(ctx context.Context, hub string, warnings *[ for _, f := range flights { add((FlightRef{Namespace: f.Namespace, Slug: f.Slug}).Canonical()) } + default: + if warnings != nil { + *warnings = append(*warnings, fmt.Sprintf("skipped hub %q: unsupported kind %q", name, kind)) + } } } sort.Strings(out) return out, nil } -func (s *FlightService) listLocalFlights(dir, namespace string) []string { - entries, err := s.Runtime.ReadDir(dir) - if err != nil { - // A missing flights.d is "no flights", not an error. - return []string{} - } - var refs []string - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if strings.HasPrefix(name, ".") { - continue - } - if stem, ok := flightStem(name); ok { - refs = append(refs, (FlightRef{Namespace: namespace, Slug: stem}).Canonical()) - } - } - sort.Strings(refs) - return refs -} - -// GetFlight loads a single flight by ref. Returns keg.ErrNotExist when no -// manifest exists. Unqualified refs first try local manifests for backward -// compatibility, then unique remote slug matches. Results are memoized for -// the life of the process (see flightCache). +// GetFlight loads a single Hub flight by ref. Returns keg.ErrNotExist when no +// manifest exists. Unqualified refs resolve through unique Hub slug matches. +// Results are memoized for the life of the process (see flightCache). func (s *FlightService) GetFlight(ctx context.Context, name string) (*Flight, error) { if f, ok := s.cachedFlight(name); ok { return f, nil @@ -387,6 +306,170 @@ func (s *FlightService) GetFlightFresh(ctx context.Context, name string) (*Fligh return s.getFlight(ctx, name) } +// ResolveFlightGraph reloads and flattens the identity-accessible transitive +// descendants of root. It intentionally bypasses the process cache so every +// MCP call observes live relations and manifests. +func (s *FlightService) ResolveFlightGraph(ctx context.Context, root *Flight) (*FlightGraph, error) { + if root == nil { + return nil, errors.New("root flight is required") + } + cfg, err := s.config() + if err != nil { + return nil, err + } + entry, ok := cfg.Hub(root.Source) + if !ok { + return nil, fmt.Errorf("root flight source %q is not a configured hub", root.Source) + } + flights, err := s.listRemoteFlights(ctx, root.Source, entry) + if err != nil { + return nil, err + } + byRef := make(map[string]*Flight, len(flights)) + for _, manifest := range flights { + flight := flightFromHub(manifest, root.Source) + byRef[flight.Name] = flight + } + root = byRef[root.Name] + if root == nil { + return nil, fmt.Errorf("root flight is no longer accessible: %w", keg.ErrNotExist) + } + return FlattenFlightGraph(ctx, root, func(_ context.Context, ref string) (*Flight, error) { + flight := byRef[ref] + if flight == nil { + return nil, keg.ErrForbidden + } + return flight, nil + }) +} + +// FlightGraph is a deterministic breadth-first projection rooted at Root. +// Available excludes the root and contains each accessible descendant once. +// Paths holds the first (therefore shortest and ordered) canonical path found. +type FlightGraph struct { + Root *Flight + Available []*Flight + Paths map[string][]string + byName map[string]*Flight +} + +// AvailableRefs returns the flattened canonical descendant list. +func (g *FlightGraph) AvailableRefs() []string { + if g == nil { + return nil + } + out := make([]string, 0, len(g.Available)) + for _, flight := range g.Available { + out = append(out, flight.Name) + } + return out +} + +// Select resolves omitted input to the root and an explicit input to either +// the root or an accessible flattened descendant. Per-tool +slug references +// are relative to the pinned root namespace. +func (g *FlightGraph) Select(raw string) (*Flight, []string, error) { + if g == nil || g.Root == nil { + return nil, nil, errors.New("flight graph root is required") + } + if strings.TrimSpace(raw) == "" { + return g.Root, append([]string(nil), g.Paths[g.Root.Name]...), nil + } + ref, err := ParseFlightRef(raw, g.Root.Namespace) + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", ErrFlightSubflightNotAllowed, err) + } + canonical := ref.Canonical() + flight := g.byName[canonical] + if flight == nil { + return nil, nil, fmt.Errorf("%w: %s is outside the accessible graph rooted at %s", ErrFlightSubflightNotAllowed, canonical, g.Root.Name) + } + return flight, append([]string(nil), g.Paths[canonical]...), nil +} + +// FlattenFlightGraph loads an ordered, bounded, single-source flight graph. +// A missing/forbidden descendant excludes that branch; all other load errors +// make the runtime graph unavailable. The root is supplied by the caller so +// root-loss classification remains transport-specific. +func FlattenFlightGraph(ctx context.Context, root *Flight, fetch func(context.Context, string) (*Flight, error)) (*FlightGraph, error) { + if root == nil || fetch == nil { + return nil, errors.New("root flight and fetch function are required") + } + if root.Name == "" { + return nil, errors.New("root flight canonical name is required") + } + type queued struct { + flight *Flight + } + graph := &FlightGraph{ + Root: root, + Paths: map[string][]string{root.Name: {root.Name}}, + byName: map[string]*Flight{root.Name: root}, + } + queue := []queued{{flight: root}} + visited := map[string]bool{} + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + parent := item.flight + if visited[parent.Name] { + continue + } + visited[parent.Name] = true + if len(parent.Subflights) > MaxFlightSubflights { + return nil, fmt.Errorf("flight %s exceeds maximum direct subflight count %d", parent.Name, MaxFlightSubflights) + } + seenDirect := map[string]struct{}{} + for _, raw := range parent.Subflights { + ref, err := ParseFlightRef(raw, parent.Namespace) + if err != nil { + return nil, fmt.Errorf("flight %s has invalid subflight %q: %w", parent.Name, raw, err) + } + childRef := ref.Canonical() + if _, duplicate := seenDirect[childRef]; duplicate { + return nil, fmt.Errorf("flight %s has duplicate canonical subflight %s", parent.Name, childRef) + } + seenDirect[childRef] = struct{}{} + if _, known := graph.byName[childRef]; known { + continue + } + child, err := fetch(ctx, childRef) + if err != nil { + if errors.Is(err, keg.ErrNotExist) || errors.Is(err, keg.ErrForbidden) || errors.Is(err, keg.ErrUnauthorized) { + continue + } + return nil, fmt.Errorf("load descendant %s: %w", childRef, err) + } + if child == nil { + continue + } + if child.Name != childRef { + return nil, fmt.Errorf("descendant %s loaded as non-canonical flight %s", childRef, child.Name) + } + if child.Source != root.Source { + return nil, fmt.Errorf("flight %s is on source %q, outside root source %q", childRef, child.Source, root.Source) + } + if len(graph.Available) >= MaxFlightGraphDescendants { + return nil, fmt.Errorf("flight graph rooted at %s exceeds maximum unique descendant count %d", root.Name, MaxFlightGraphDescendants) + } + graph.byName[childRef] = child + graph.Available = append(graph.Available, child) + path := append([]string(nil), graph.Paths[parent.Name]...) + graph.Paths[childRef] = append(path, childRef) + queue = append(queue, queued{flight: child}) + } + } + // No cycle or depth pass. A subflight relation is an ordered list entry on + // its parent, not an assertion about the shape of the whole graph, so the + // walk above tolerates any shape: `visited` skips a parent already + // expanded and `graph.byName` skips a child already loaded, which makes a + // cycle finite rather than fatal. Authority is never inherited from an + // ancestor, so mutual reference grants nothing. Traversal cost is bounded + // by MaxFlightGraphDescendants, checked inline above and dedup-based, so + // it holds for cyclic graphs too. + return graph, nil +} + func (s *FlightService) getFlight(ctx context.Context, name string) (*Flight, error) { cfg, err := s.config() if err != nil { @@ -400,12 +483,6 @@ func (s *FlightService) getFlight(ctx context.Context, name string) (*Flight, er return s.getFlightInNamespace(ctx, cfg, ref) } - if f, err := s.getLocalFlightAnyHub(cfg, ref.Slug); err == nil { - return f, nil - } else if !errors.Is(err, keg.ErrNotExist) { - return nil, err - } - var matches []*Flight for _, hubName := range s.allHubNames(cfg) { entry, ok := cfg.Hub(hubName) @@ -416,7 +493,7 @@ func (s *FlightService) getFlight(ctx context.Context, name string) (*Flight, er if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { + if kind != HubKindRemote && kind != HubKindReadonly { continue } flights, listErr := s.listRemoteFlights(ctx, hubName, entry) @@ -450,12 +527,8 @@ func (s *FlightService) getFlightInNamespace(ctx context.Context, cfg *Config, r if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { - dir, err := s.localFlightsDirFor(entry) - if err != nil { - return nil, err - } - return s.getLocalFlight(dir, ref.Namespace, ref.Slug) + if kind != HubKindRemote && kind != HubKindReadonly { + return nil, fmt.Errorf("hub %q has unsupported kind %q", hubName, kind) } if strings.TrimSpace(entry.URL) == "" { return nil, fmt.Errorf("hub %q has no url configured", hubName) @@ -471,63 +544,6 @@ func (s *FlightService) getFlightInNamespace(ctx context.Context, cfg *Config, r return flightFromHub(*hf, hubName), nil } -func (s *FlightService) getLocalFlightAnyHub(cfg *Config, slug string) (*Flight, error) { - for _, hubName := range s.allHubNames(cfg) { - entry, ok := cfg.Hub(hubName) - if !ok { - continue - } - kind := strings.TrimSpace(entry.Kind) - if kind != HubKindLocal { - continue - } - dir, err := s.localFlightsDirFor(entry) - if err != nil { - return nil, err - } - f, err := s.getLocalFlight(dir, localFlightNamespace(entry), slug) - if err == nil { - return f, nil - } - if !errors.Is(err, keg.ErrNotExist) { - return nil, err - } - } - return nil, keg.ErrNotExist -} - -func (s *FlightService) getLocalFlight(dir, namespace, slug string) (*Flight, error) { - for _, ext := range []string{".yaml", ".yml"} { - path := filepath.Join(dir, slug+ext) - b, readErr := s.Runtime.ReadFile(path) - if readErr != nil { - continue - } - var m FlightManifest - if err := yaml.Unmarshal(b, &m); err != nil { - return nil, fmt.Errorf("parse flight %q: %w", slug, err) - } - if err := validateFlightManifest(&m); err != nil { - return nil, fmt.Errorf("parse flight %q: %w", slug, err) - } - normalizeFlightManifest(&m) - ref := FlightRef{Namespace: namespace, Slug: slug} - return &Flight{Name: ref.Canonical(), Namespace: namespace, Slug: slug, Source: "local", ManifestHash: hashFlightManifest(m), FlightManifest: m}, nil - } - return nil, fmt.Errorf("flight %q not found: %w", slug, keg.ErrNotExist) -} - -// flightStem returns the flight name for a manifest filename and whether the -// filename is a flight manifest (.yaml/.yml). -func flightStem(filename string) (string, bool) { - for _, ext := range []string{".yaml", ".yml"} { - if strings.HasSuffix(filename, ext) { - return strings.TrimSuffix(filename, ext), true - } - } - return "", false -} - func normalizeFlightManifest(m *FlightManifest) { if m == nil { return @@ -560,11 +576,14 @@ func normalizeFlightManifest(m *FlightManifest) { m.Cover[i].Keg = strings.TrimSpace(m.Cover[i].Keg) m.Cover[i].Role = normalizeFlightRole(m.Cover[i].Role) } + for i := range m.Subflights { + m.Subflights[i] = strings.TrimSpace(m.Subflights[i]) + } } // hashFlightManifest returns an internal change token for a normalized flight // manifest. JSON field order is fixed by FlightManifest's struct definition, -// and SHA-256 keeps local and Hub-backed flights on the same comparison path. +// and SHA-256 keeps client and Hub revision comparison deterministic. func hashFlightManifest(m FlightManifest) string { normalizeFlightManifest(&m) b, err := json.Marshal(m) @@ -574,7 +593,10 @@ func hashFlightManifest(m FlightManifest) string { return fmt.Sprintf("%x", sha256.Sum256(b)) } -func validateFlightManifest(m *FlightManifest) error { +// FlightManifestHash returns the deterministic revision token for a manifest. +func FlightManifestHash(m FlightManifest) string { return hashFlightManifest(m) } + +func validateFlightManifest(m *FlightManifest, namespace string) error { if m == nil { return nil } @@ -613,6 +635,25 @@ func validateFlightManifest(m *FlightManifest) error { return fmt.Errorf("invalid flight cover role %q", strings.TrimSpace(roleRaw)) } } + if len(m.Subflights) > MaxFlightSubflights { + return fmt.Errorf("subflight count exceeds %d", MaxFlightSubflights) + } + seenSubflights := map[string]struct{}{} + for _, raw := range m.Subflights { + raw = strings.TrimSpace(raw) + if raw == "" { + return errors.New("subflight reference cannot be empty") + } + ref, err := ParseFlightRef(raw, namespace) + if err != nil { + return fmt.Errorf("invalid subflight reference %q: %w", raw, err) + } + canonical := ref.Canonical() + if _, duplicate := seenSubflights[canonical]; duplicate { + return fmt.Errorf("duplicate subflight reference %q", raw) + } + seenSubflights[canonical] = struct{}{} + } return nil } @@ -741,17 +782,10 @@ func ParseFlightCoverSpecs(specs []string) ([]FlightCover, error) { return out, nil } -func localFlightNamespace(entry HubEntry) string { - if ns := strings.TrimPrefix(strings.TrimSpace(entry.DefaultNamespace), "@"); ns != "" { - return ns - } - return LocalHubName -} - func (s *FlightService) allHubNames(cfg *Config) []string { hubs := cfg.Hubs() if len(hubs) == 0 { - return dedupeStrings([]string{cfg.localHubName(), cfg.resolveHubName()}) + return dedupeStrings([]string{cfg.resolveHubName()}) } names := make([]string, 0, len(hubs)) for n := range hubs { @@ -812,16 +846,21 @@ func flightFromHub(hf HubFlight, hubName string) *Flight { Visibility: hf.Visibility, Capabilities: append([]FlightCapability{}, hf.Capabilities...), Cover: cover, + Subflights: append([]string(nil), hf.Subflights...), Instructions: hf.Instructions, } normalizeFlightManifest(&m) ref := FlightRef{Namespace: hf.Namespace, Slug: hf.Slug} + manifestHash := hf.Hash + if manifestHash == "" { + manifestHash = hashFlightManifest(m) + } return &Flight{ Name: ref.Canonical(), Namespace: hf.Namespace, Slug: hf.Slug, Source: hubName, - ManifestHash: hashFlightManifest(m), + ManifestHash: manifestHash, FlightManifest: m, } } diff --git a/pkg/tapper/flight_test.go b/pkg/tapper/flight_test.go index da006e09..225f4bd4 100644 --- a/pkg/tapper/flight_test.go +++ b/pkg/tapper/flight_test.go @@ -1,154 +1,19 @@ package tapper_test import ( + "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" - "strings" "sync/atomic" "testing" + "github.com/jlrickert/tapper/pkg/keg" "github.com/jlrickert/tapper/pkg/tapper" "github.com/stretchr/testify/require" ) -func TestFlightService_ListAndGet(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - // A user config whose local hub basePath we control, so flights.d is at a - // known location. - userCfg := `hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -` - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - - flightYAML := `title: Backend work -allowedKegs: - - personal - - "@local/notes" -instructions: | - Only touch backend kegs. -` - require.NoError(t, fx.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/backend.yaml", []byte(flightYAML), 0o644)) - // A non-manifest file and a dotfile must be ignored. - require.NoError(t, fx.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/README.md", []byte("ignore me"), 0o644)) - - names, err := tap.ListFlights(fx.Context(), tapper.ListFlightsOptions{}) - require.NoError(t, err) - require.Equal(t, []string{"@local/+backend"}, names) - - f, err := tap.GetFlight(fx.Context(), tapper.GetFlightOptions{Name: "backend"}) - require.NoError(t, err) - require.Equal(t, "@local/+backend", f.Name) - require.Equal(t, "Backend work", f.Title) - require.Equal(t, []string{"personal", "@local/notes"}, f.AllowedKegs) - require.Equal(t, []tapper.FlightCover{ - {Keg: "personal", Role: tapper.FlightRoleEditor}, - {Namespace: "local", Keg: "notes", Role: tapper.FlightRoleEditor}, - }, f.Cover) - require.Contains(t, f.Instructions, "backend kegs") - require.Equal(t, "local", f.Source) - require.Len(t, f.ManifestHash, 64) - encoded, err := json.Marshal(f) - require.NoError(t, err) - require.NotContains(t, string(encoded), "manifest_hash") - - _, err = tap.GetFlight(fx.Context(), tapper.GetFlightOptions{Name: "nope"}) - require.Error(t, err, "missing flight must error") -} - -func TestFlightService_ManifestHashUsesNormalizedContent(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -`), 0o644)) - - path := "/home/testuser/kegs/flights.d/hash.yaml" - baseline := `title: Focused -visibility: private -capabilities: [manage_flights, full_access] -cover: - - namespace: local - keg: personal - role: editor -instructions: Stay focused. -` - require.NoError(t, fx.Runtime().AtomicWriteFile(path, []byte(baseline), 0o644)) - flight, err := tap.FlightService.GetFlightFresh(fx.Context(), "+hash") - require.NoError(t, err) - baselineHash := flight.ManifestHash - require.Len(t, baselineHash, 64) - - equivalent := `instructions: Stay focused. -cover: -- role: editor - keg: personal - namespace: local -capabilities: -- full_access -- manage_flights -visibility: private -title: Focused -` - require.NoError(t, fx.Runtime().AtomicWriteFile(path, []byte(equivalent), 0o644)) - flight, err = tap.FlightService.GetFlightFresh(fx.Context(), "+hash") - require.NoError(t, err) - require.Equal(t, baselineHash, flight.ManifestHash) - - changes := map[string]string{ - "title": strings.Replace(baseline, "title: Focused", "title: Changed", 1), - "visibility": strings.Replace(baseline, "visibility: private", "visibility: public", 1), - "capabilities": strings.Replace(baseline, "capabilities: [manage_flights, full_access]", "capabilities: []", 1), - "cover": strings.Replace(baseline, "role: editor", "role: viewer", 1), - "instructions": strings.Replace(baseline, "instructions: Stay focused.", "instructions: Changed.", 1), - } - for name, manifest := range changes { - t.Run(name, func(t *testing.T) { - require.NoError(t, fx.Runtime().AtomicWriteFile(path, []byte(manifest), 0o644)) - changed, err := tap.FlightService.GetFlightFresh(fx.Context(), "+hash") - require.NoError(t, err) - require.NotEqual(t, baselineHash, changed.ManifestHash) - }) - } -} - -func TestFlightService_NoFlightsDir(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - // No flights.d anywhere: discovery yields an empty list, not an error. - names, err := tap.ListFlights(fx.Context(), tapper.ListFlightsOptions{}) - require.NoError(t, err) - require.Empty(t, names) -} - func TestFlightService_ListHubFilterContactsOnlySelectedHub(t *testing.T) { t.Parallel() var firstCalls, secondCalls atomic.Int32 @@ -187,6 +52,49 @@ func TestFlightService_ListHubFilterContactsOnlySelectedHub(t *testing.T) { require.EqualValues(t, 1, secondCalls.Load(), "unfiltered listing should retain all-hub behavior") } +func TestFlightService_RemoteGraphUsesOneFreshBatchPerResolution(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/flights", r.URL.Path) + require.Equal(t, "Bearer tok", r.Header.Get("Authorization")) + generation := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + if generation == 1 { + _ = json.NewEncoder(w).Encode([]tapper.HubFlight{ + {Namespace: "team", Slug: "root", Title: "generation one", Subflights: []string{"+child", "+hidden"}}, + {Namespace: "team", Slug: "child", Subflights: []string{"+root", "+shared"}}, + {Namespace: "team", Slug: "shared"}, + }) + return + } + _ = json.NewEncoder(w).Encode([]tapper.HubFlight{ + {Namespace: "team", Slug: "root", Title: "generation two", Subflights: []string{"+next"}}, + {Namespace: "team", Slug: "next"}, + }) + })) + defer srv.Close() + + fx := NewSandbox(t) + require.NoError(t, fx.Setwd("/home/testuser")) + tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) + require.NoError(t, err) + config := "hubs:\n atlas: {kind: remote, url: " + srv.URL + ", token: tok}\n" + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) + root := &tapper.Flight{Name: "@team/+root", Namespace: "team", Slug: "root", Source: "atlas"} + + first, err := tap.FlightService.ResolveFlightGraph(t.Context(), root) + require.NoError(t, err) + require.Equal(t, int32(1), calls.Load()) + require.Equal(t, "generation one", first.Root.Title) + require.Equal(t, []string{"@team/+child", "@team/+shared"}, first.AvailableRefs(), "cycles and shared descendants must terminate; absent branches are omitted") + + second, err := tap.FlightService.ResolveFlightGraph(t.Context(), root) + require.NoError(t, err) + require.Equal(t, int32(2), calls.Load(), "each resolution must issue exactly one batch request") + require.Equal(t, "generation two", second.Root.Title) + require.Equal(t, []string{"@team/+next"}, second.AvailableRefs(), "a later resolution must observe live graph changes") +} + func TestParseFlightRef(t *testing.T) { t.Parallel() tests := []struct { @@ -283,70 +191,6 @@ func TestFlightRoleFor_EmptyCoverDeniesAll(t *testing.T) { require.False(t, ok) } -func TestFlightService_RejectsUnknownCapabilities(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -`), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/flights.d/bad.yaml", []byte("capabilities: [shell_access]\n"), 0o644)) - - _, err = tap.GetFlight(fx.Context(), tapper.GetFlightOptions{Name: "+bad"}) - require.Error(t, err) - require.Contains(t, err.Error(), `unknown flight capability "shell_access"`) -} - -func TestFlightService_RejectsUnknownCoverRoles(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -`), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/bad-role.yaml", - []byte("cover:\n - keg: personal\n role: owner\n"), - 0o644, - )) - - _, err = tap.GetFlight(fx.Context(), tapper.GetFlightOptions{Name: "+bad-role"}) - require.ErrorContains(t, err, `invalid flight cover role "owner"`) -} - -func TestFlightService_AcceptsFullAccessCapability(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -`), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/flights.d/full.yaml", []byte("capabilities: [full_access]\n"), 0o644)) - - flight, err := tap.GetFlight(fx.Context(), tapper.GetFlightOptions{Name: "+full"}) - require.NoError(t, err) - require.True(t, flight.HasCapability(tapper.FlightCapabilityFullAccess)) - require.False(t, flight.HasCapability(tapper.FlightCapabilityManageFlights)) -} - -// A viewer cap must survive repeated RoleFor calls: the legacy AllowedKegs -// mirror used to be re-merged into the cover as editor rows on every call, -// leaving viewer enforcement to a fragile ordering invariant. func TestFlightRoleFor_ViewerCapStableAcrossCalls(t *testing.T) { t.Parallel() flight := &tapper.Flight{ @@ -387,176 +231,224 @@ func TestFlightRoleFor_LegacyAllowedKegsRoles(t *testing.T) { require.Equal(t, tapper.FlightRoleViewer, role) } -func TestFlightEnforcement_LocalHubPathIdentity(t *testing.T) { +func TestFlattenFlightGraph_BreadthFirstDedupAndSelection(t *testing.T) { t.Parallel() - tap, personalID, privateID := newLocalFlightEnforcementFixture(t) - - got, err := tap.Cat(t.Context(), tapper.CatOptions{ - NodeIDs: []string{personalID}, - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "personal", - Flight: "+focused", - }, - ContentOnly: true, + flight := func(name string, children ...string) *tapper.Flight { + ref, err := tapper.ParseFlightRef(name, "") + require.NoError(t, err) + return &tapper.Flight{ + Name: name, Namespace: ref.Namespace, Slug: ref.Slug, Source: "atlas", + FlightManifest: tapper.FlightManifest{ + Visibility: tapper.FlightVisibilityPrivate, + Subflights: children, + }, + } + } + root := flight("@team/+root", "+right", "@team/+left") + flights := map[string]*tapper.Flight{ + "@team/+left": flight("@team/+left", "+shared", "@other/+grand"), + "@team/+right": flight("@team/+right", "+shared"), + "@team/+shared": flight("@team/+shared"), + "@other/+grand": flight("@other/+grand"), + } + fetched := map[string]int{} + graph, err := tapper.FlattenFlightGraph(t.Context(), root, func(_ context.Context, ref string) (*tapper.Flight, error) { + fetched[ref]++ + return flights[ref], nil }) require.NoError(t, err) - require.Contains(t, got, "# Personal") - - _, err = tap.Cat(t.Context(), tapper.CatOptions{ - NodeIDs: []string{privateID}, - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "private", - Flight: "+focused", - }, - ContentOnly: true, - }) - require.Error(t, err) - var restriction *tapper.FlightRestrictionError - require.ErrorAs(t, err, &restriction) - require.Contains(t, err.Error(), `keg "@local/private" is not available in flight`) + require.Equal(t, []string{"@team/+right", "@team/+left", "@team/+shared", "@other/+grand"}, graph.AvailableRefs()) + require.Equal(t, 1, fetched["@team/+shared"], "shared descendant must be fetched once") + active, path, err := graph.Select("+shared") + require.NoError(t, err) + require.Equal(t, "@team/+shared", active.Name) + require.Equal(t, []string{"@team/+root", "@team/+right", "@team/+shared"}, path) + active, path, err = graph.Select("@team/+root") + require.NoError(t, err) + require.Same(t, root, active) + require.Equal(t, []string{"@team/+root"}, path) } -func TestFlightEnforcement_ViewerCoverAllowsReadsAndRejectsWrites(t *testing.T) { +func TestFlattenFlightGraph_RejectsMalformedGraphs(t *testing.T) { t.Parallel() - tap, personalID, _ := newLocalFlightEnforcementFixture(t) - - _, err := tap.Cat(t.Context(), tapper.CatOptions{ - NodeIDs: []string{personalID}, - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "personal", - Flight: "+focused", + base := func(name, source string, children ...string) *tapper.Flight { + ref, err := tapper.ParseFlightRef(name, "") + require.NoError(t, err) + return &tapper.Flight{Name: name, Namespace: ref.Namespace, Slug: ref.Slug, Source: source, + FlightManifest: tapper.FlightManifest{Visibility: tapper.FlightVisibilityPrivate, Subflights: children}} + } + for _, tc := range []struct { + name string + root *tapper.Flight + flights map[string]*tapper.Flight + wantErr string + }{ + { + name: "cross source", + root: base("@team/+root", "atlas", "@team/+child"), + flights: map[string]*tapper.Flight{ + "@team/+child": base("@team/+child", "other"), + }, + wantErr: "outside root source", }, - ContentOnly: true, - }) - require.NoError(t, err) - - _, err = tap.Create(t.Context(), tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "personal", - Flight: "+focused", + // A cycle is deliberately absent here: it is no longer malformed. See + // TestFlattenFlightGraph_ToleratesCycles. + { + name: "canonical direct duplicate", + root: base("@team/+root", "atlas", "+child", "@team/+child"), + flights: map[string]*tapper.Flight{ + "@team/+child": base("@team/+child", "atlas"), + }, + wantErr: "duplicate canonical", }, - Title: "Blocked Write", - }) - require.Error(t, err) - var restriction *tapper.FlightRestrictionError - require.ErrorAs(t, err, &restriction) - require.Contains(t, err.Error(), `keg "@local/personal" is viewer-only in flight`) + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tapper.FlattenFlightGraph(t.Context(), tc.root, func(_ context.Context, ref string) (*tapper.Flight, error) { + return tc.flights[ref], nil + }) + require.ErrorContains(t, err, tc.wantErr) + }) + } } -func TestFlightBypass_AllowsReadOutsideCover(t *testing.T) { - t.Parallel() - tap, _, privateID := newLocalFlightEnforcementFixture(t) - - got, err := tap.Cat(t.Context(), tapper.CatOptions{ - NodeIDs: []string{privateID}, - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "private", - Flight: "+focused", - BypassFlightRestrictions: true, - }, - ContentOnly: true, +func TestFlattenFlightGraph_ExcludesInaccessibleBranchAndKeepsIndependentAuthority(t *testing.T) { + root := &tapper.Flight{Name: "@team/+root", Namespace: "team", Slug: "root", Source: "atlas", + FlightManifest: tapper.FlightManifest{Subflights: []string{"+hidden", "+manager"}}} + manager := &tapper.Flight{Name: "@team/+manager", Namespace: "team", Slug: "manager", Source: "atlas", + FlightManifest: tapper.FlightManifest{Capabilities: []tapper.FlightCapability{tapper.FlightCapabilityManageKegs}}} + graph, err := tapper.FlattenFlightGraph(t.Context(), root, func(_ context.Context, ref string) (*tapper.Flight, error) { + if ref == "@team/+hidden" { + return nil, keg.ErrForbidden + } + return manager, nil }) require.NoError(t, err) - require.Contains(t, got, "# Private") -} - -func TestFlightBypass_AllowsWriteThroughViewerCover(t *testing.T) { - t.Parallel() - tap, _, _ := newLocalFlightEnforcementFixture(t) - - node, err := tap.Create(t.Context(), tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "personal", - Flight: "+focused", - BypassFlightRestrictions: true, - }, - Title: "Allowed Write", - Attrs: map[string]string{"type": "note"}, - }) + require.Equal(t, []string{"@team/+manager"}, graph.AvailableRefs()) + _, _, err = graph.Select("+hidden") + require.ErrorIs(t, err, tapper.ErrFlightSubflightNotAllowed) + selected, _, err := graph.Select("+manager") require.NoError(t, err) - require.NotEmpty(t, node.Path()) + require.True(t, selected.HasCapability(tapper.FlightCapabilityManageKegs)) } -func TestFlightEnforcement_FullAccessBypassesCoverCaps(t *testing.T) { - t.Parallel() - tap, _, privateID := newLocalFlightEnforcementFixture(t) - manifest := `title: Full access -capabilities: [full_access] -cover: - - namespace: local - keg: personal - role: viewer -` - require.NoError(t, tap.Runtime.AtomicWriteFile("/home/testuser/kegs/flights.d/focused.yaml", []byte(manifest), 0o644)) - - got, err := tap.Cat(t.Context(), tapper.CatOptions{ - NodeIDs: []string{privateID}, - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "private", - Flight: "+focused", - }, - ContentOnly: true, +func TestFlattenFlightGraph_EnforcesDirectAndUniqueBounds(t *testing.T) { + direct := &tapper.Flight{Name: "@team/+direct", Namespace: "team", Slug: "direct", Source: "atlas"} + for i := 0; i <= tapper.MaxFlightSubflights; i++ { + direct.Subflights = append(direct.Subflights, fmt.Sprintf("@team/+d%d", i)) + } + _, err := tapper.FlattenFlightGraph(t.Context(), direct, func(_ context.Context, ref string) (*tapper.Flight, error) { + return &tapper.Flight{Name: ref, Namespace: "team", Source: "atlas"}, nil }) - require.NoError(t, err) - require.Contains(t, got, "# Private") - - _, err = tap.Create(t.Context(), tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{ - Keg: "personal", - Flight: "+focused", - }, - Title: "Full Access Write", - Attrs: map[string]string{"type": "note"}, + require.ErrorContains(t, err, "maximum direct subflight") + + // A long chain is no longer rejected: depth is not a bound. Only the + // unique-descendant cap limits how far a traversal will go. + chain := map[string]*tapper.Flight{} + root := &tapper.Flight{Name: "@team/+n0", Namespace: "team", Slug: "n0", Source: "atlas"} + previous := root + for i := 1; i <= 32; i++ { + name := fmt.Sprintf("@team/+n%d", i) + previous.Subflights = []string{name} + next := &tapper.Flight{Name: name, Namespace: "team", Slug: fmt.Sprintf("n%d", i), Source: "atlas"} + chain[name] = next + previous = next + } + deep, err := tapper.FlattenFlightGraph(t.Context(), root, func(_ context.Context, ref string) (*tapper.Flight, error) { + return chain[ref], nil }) require.NoError(t, err) + require.Len(t, deep.Available, 32) + + wide := &tapper.Flight{Name: "@team/+wide", Namespace: "team", Slug: "wide", Source: "atlas"} + flights := map[string]*tapper.Flight{} + for i := 0; i < 64; i++ { + childName := fmt.Sprintf("@team/+c%d", i) + wide.Subflights = append(wide.Subflights, childName) + child := &tapper.Flight{Name: childName, Namespace: "team", Slug: fmt.Sprintf("c%d", i), Source: "atlas"} + flights[childName] = child + for j := 0; j < 5; j++ { + grandName := fmt.Sprintf("@team/+c%d-g%d", i, j) + child.Subflights = append(child.Subflights, grandName) + flights[grandName] = &tapper.Flight{Name: grandName, Namespace: "team", Slug: fmt.Sprintf("c%d-g%d", i, j), Source: "atlas"} + } + } + _, err = tapper.FlattenFlightGraph(t.Context(), wide, func(_ context.Context, ref string) (*tapper.Flight, error) { + return flights[ref], nil + }) + require.ErrorContains(t, err, "maximum unique descendant") } -func newLocalFlightEnforcementFixture(t *testing.T) (*tapper.Tap, string, string) { - t.Helper() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) +func TestFlattenFlightGraph_KeepsShortestPathToSharedDescendant(t *testing.T) { + build := func(longDepth int) (*tapper.Flight, map[string]*tapper.Flight) { + root := &tapper.Flight{Name: "@team/+root", Namespace: "team", Slug: "root", Source: "atlas"} + shared := &tapper.Flight{Name: "@team/+shared", Namespace: "team", Slug: "shared", Source: "atlas"} + root.Subflights = []string{shared.Name, "@team/+long-1"} + flights := map[string]*tapper.Flight{shared.Name: shared} + previous := root + for i := 1; i < longDepth; i++ { + name := fmt.Sprintf("@team/+long-%d", i) + current := &tapper.Flight{Name: name, Namespace: "team", Slug: fmt.Sprintf("long-%d", i), Source: "atlas"} + flights[name] = current + if previous != root { + previous.Subflights = []string{name} + } + previous = current + } + previous.Subflights = []string{shared.Name} + return root, flights + } - userCfg := `fallbackNamespace: local -hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -` - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - for _, name := range []string{"personal", "private"} { - _, err := tap.InitKeg(t.Context(), tapper.InitOptions{Keg: name, Namespace: "local"}) + // A descendant reachable both directly and down a long chain keeps the + // deterministic shortest selection path, however long the other route is. + for _, longDepth := range []int{4, 12} { + root, flights := build(longDepth) + graph, err := tapper.FlattenFlightGraph(t.Context(), root, func(_ context.Context, ref string) (*tapper.Flight, error) { + return flights[ref], nil + }) + require.NoError(t, err) + sharedRef := "@team/+shared" + _, path, err := graph.Select(sharedRef) require.NoError(t, err) - require.NoError(t, tap.CreateSchema(t.Context(), tapper.SchemaOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: name}, - Data: []byte("type: note\n"), - })) + require.Equal(t, []string{root.Name, sharedRef}, path) } - personalID, err := tap.Create(t.Context(), tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "personal"}, - Title: "Personal", - Attrs: map[string]string{"type": "note"}, +} + +// TestFlattenFlightGraph_ToleratesCycles is the property the removal of the +// cycle and depth passes rests on: a subflight entry is a list item, not an +// assertion about graph shape, so a cyclic manifest must flatten to a finite, +// usable graph rather than erroring or looping. Authority is never inherited +// from an ancestor, so mutual reference grants nothing either. +func TestFlattenFlightGraph_ToleratesCycles(t *testing.T) { + a := &tapper.Flight{Name: "@team/+a", Namespace: "team", Slug: "a", Source: "atlas", FlightManifest: tapper.FlightManifest{Subflights: []string{"@team/+b"}}} + b := &tapper.Flight{Name: "@team/+b", Namespace: "team", Slug: "b", Source: "atlas", FlightManifest: tapper.FlightManifest{Subflights: []string{"@team/+a"}}} + flights := map[string]*tapper.Flight{a.Name: a, b.Name: b} + + graph, err := tapper.FlattenFlightGraph(t.Context(), a, func(_ context.Context, ref string) (*tapper.Flight, error) { + return flights[ref], nil }) + require.NoError(t, err, "a cycle must flatten, not fail") + require.Equal(t, []string{"@team/+b"}, graph.AvailableRefs()) + + selected, path, err := graph.Select("@team/+b") + require.NoError(t, err) + require.Equal(t, b.Name, selected.Name) + require.Equal(t, []string{a.Name, b.Name}, path) + + // Selecting the root back through the cycle resolves to the root itself + // rather than re-entering it as its own descendant. + rootAgain, rootPath, err := graph.Select(a.Name) require.NoError(t, err) - privateID, err := tap.Create(t.Context(), tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "private"}, - Title: "Private", - Attrs: map[string]string{"type": "note"}, + require.Equal(t, a.Name, rootAgain.Name) + require.Equal(t, []string{a.Name}, rootPath) + + // A three-flight cycle is equally finite. + x := &tapper.Flight{Name: "@team/+x", Namespace: "team", Slug: "x", Source: "atlas", FlightManifest: tapper.FlightManifest{Subflights: []string{"@team/+y"}}} + y := &tapper.Flight{Name: "@team/+y", Namespace: "team", Slug: "y", Source: "atlas", FlightManifest: tapper.FlightManifest{Subflights: []string{"@team/+z"}}} + z := &tapper.Flight{Name: "@team/+z", Namespace: "team", Slug: "z", Source: "atlas", FlightManifest: tapper.FlightManifest{Subflights: []string{"@team/+x"}}} + ring := map[string]*tapper.Flight{x.Name: x, y.Name: y, z.Name: z} + ringGraph, err := tapper.FlattenFlightGraph(t.Context(), x, func(_ context.Context, ref string) (*tapper.Flight, error) { + return ring[ref], nil }) require.NoError(t, err) - - flightYAML := `title: Focused -cover: - - namespace: local - keg: personal - role: viewer -` - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/flights.d/focused.yaml", []byte(flightYAML), 0o644)) - return tap, personalID.PathNumeric(), privateID.PathNumeric() + require.Equal(t, []string{"@team/+y", "@team/+z"}, ringGraph.AvailableRefs()) } diff --git a/pkg/tapper/hub_flights.go b/pkg/tapper/hub_flights.go index 922477ec..785f0985 100644 --- a/pkg/tapper/hub_flights.go +++ b/pkg/tapper/hub_flights.go @@ -27,6 +27,8 @@ type HubFlight struct { Visibility string `json:"visibility"` Capabilities []FlightCapability `json:"capabilities"` Cover []HubFlightCover `json:"cover"` + Subflights []string `json:"subflights"` + Hash string `json:"hash,omitempty"` } func ListUserFlights(ctx context.Context, hubURL, token string) ([]HubFlight, error) { @@ -75,7 +77,7 @@ func ListUserFlights(ctx context.Context, hubURL, token string) ([]HubFlight, er func GetHubFlight(ctx context.Context, hubURL, token, namespace, slug string) (*HubFlight, error) { var out HubFlight - if err := doHubFlightJSON(ctx, http.MethodGet, hubURL, token, flightManifestPath(namespace, slug), nil, &out); err != nil { + if err := doHubFlightJSON(ctx, http.MethodGet, hubURL, token, flightManifestPath(namespace, slug), "", nil, &out); err != nil { return nil, err } if err := validateHubFlight(out); err != nil { @@ -86,7 +88,7 @@ func GetHubFlight(ctx context.Context, hubURL, token, namespace, slug string) (* func CreateHubFlight(ctx context.Context, hubURL, token, namespace string, flight HubFlight) (*HubFlight, error) { var out HubFlight - if err := doHubFlightJSON(ctx, http.MethodPost, hubURL, token, fmt.Sprintf("/api/v1/@%s/flights", namespace), flight, &out); err != nil { + if err := doHubFlightJSON(ctx, http.MethodPost, hubURL, token, fmt.Sprintf("/api/v1/@%s/flights", namespace), "", flight, &out); err != nil { return nil, err } if err := validateHubFlight(out); err != nil { @@ -95,9 +97,9 @@ func CreateHubFlight(ctx context.Context, hubURL, token, namespace string, fligh return &out, nil } -func UpdateHubFlight(ctx context.Context, hubURL, token, namespace, slug string, flight HubFlight) (*HubFlight, error) { +func UpdateHubFlight(ctx context.Context, hubURL, token, namespace, slug string, flight HubFlight, expectedHash string) (*HubFlight, error) { var out HubFlight - if err := doHubFlightJSON(ctx, http.MethodPut, hubURL, token, flightManifestPath(namespace, slug), flight, &out); err != nil { + if err := doHubFlightJSON(ctx, http.MethodPut, hubURL, token, flightManifestPath(namespace, slug), expectedHash, flight, &out); err != nil { return nil, err } if err := validateHubFlight(out); err != nil { @@ -119,18 +121,19 @@ func validateHubFlight(flight HubFlight) error { Visibility: flight.Visibility, Capabilities: flight.Capabilities, Cover: cover, - }) + Subflights: flight.Subflights, + }, flight.Namespace) } -func DeleteHubFlight(ctx context.Context, hubURL, token, namespace, slug string) error { - return doHubFlightJSON(ctx, http.MethodDelete, hubURL, token, flightManifestPath(namespace, slug), nil, nil) +func DeleteHubFlight(ctx context.Context, hubURL, token, namespace, slug, expectedHash string) error { + return doHubFlightJSON(ctx, http.MethodDelete, hubURL, token, flightManifestPath(namespace, slug), expectedHash, nil, nil) } func flightManifestPath(namespace, slug string) string { return fmt.Sprintf("/api/v1/@%s/+%s", namespace, slug) } -func doHubFlightJSON(ctx context.Context, method, hubURL, token, path string, payload any, out any) error { +func doHubFlightJSON(ctx context.Context, method, hubURL, token, path, expectedHash string, payload any, out any) error { base, err := normalizeHubURL(hubURL) if err != nil { return err @@ -150,6 +153,9 @@ func doHubFlightJSON(ctx context.Context, method, hubURL, token, path string, pa if token != "" { req.Header.Set("Authorization", "Bearer "+token) } + if method == http.MethodPut || method == http.MethodDelete { + req.Header.Set("If-Match", expectedHash) + } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") @@ -175,6 +181,15 @@ func doHubFlightJSON(ctx context.Context, method, hubURL, token, path string, pa case http.StatusConflict: return fmt.Errorf("hub: %s %s conflicts with existing state%s: %w", method, path, readHubError(resp), keg.ErrExist) + case http.StatusPreconditionRequired: + return fmt.Errorf("hub: %s %s: %w", method, path, keg.ErrPreconditionRequired) + case http.StatusPreconditionFailed: + var env struct { + CurrentHash string `json:"currentHash"` + CurrentContent string `json:"currentContent"` + } + _ = json.NewDecoder(resp.Body).Decode(&env) + return &keg.PreconditionConflictError{Resource: path, CurrentHash: env.CurrentHash, CurrentContent: []byte(env.CurrentContent)} case http.StatusNotFound: return fmt.Errorf("hub: %s %s returned not found%s: %w", method, path, readHubError(resp), keg.ErrNotExist) diff --git a/pkg/tapper/hub_flights_precondition_test.go b/pkg/tapper/hub_flights_precondition_test.go new file mode 100644 index 00000000..f657ee8e --- /dev/null +++ b/pkg/tapper/hub_flights_precondition_test.go @@ -0,0 +1,60 @@ +package tapper_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/tapper" + "github.com/stretchr/testify/require" +) + +func TestHubFlightWritesSendIfMatch(t *testing.T) { + t.Parallel() + seen := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen[r.Method] = r.Header.Get("If-Match") + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"namespace":"foldwise","slug":"agent-work","title":"Agent Work","visibility":"private","capabilities":[],"cover":[],"subflights":[],"hash":"next"}`)) + })) + t.Cleanup(srv.Close) + + _, err := tapper.UpdateHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", tapper.HubFlight{Namespace: "foldwise", Slug: "agent-work", Visibility: "private"}, "update-hash") + require.NoError(t, err) + require.NoError(t, tapper.DeleteHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", "delete-hash")) + require.Equal(t, "update-hash", seen[http.MethodPut]) + require.Equal(t, "delete-hash", seen[http.MethodDelete]) +} + +func TestHubFlightWritesDecodePreconditionErrors(t *testing.T) { + t.Parallel() + t.Run("required", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPreconditionRequired) + })) + t.Cleanup(srv.Close) + err := tapper.DeleteHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", "") + require.ErrorIs(t, err, keg.ErrPreconditionRequired) + }) + + t.Run("conflict", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = w.Write([]byte(`{"currentHash":"fresh","currentContent":"title: Current\n","operationPerformed":false}`)) + })) + t.Cleanup(srv.Close) + _, err := tapper.UpdateHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", tapper.HubFlight{}, "stale") + require.ErrorIs(t, err, keg.ErrConflict) + var conflict *keg.PreconditionConflictError + require.ErrorAs(t, err, &conflict) + require.Equal(t, "fresh", conflict.CurrentHash) + require.Equal(t, "title: Current\n", string(conflict.CurrentContent)) + }) +} diff --git a/pkg/tapper/hub_flights_test.go b/pkg/tapper/hub_flights_test.go index 588b7ff3..4136c18d 100644 --- a/pkg/tapper/hub_flights_test.go +++ b/pkg/tapper/hub_flights_test.go @@ -66,9 +66,9 @@ func TestHubFlights_ClientPaths(t *testing.T) { require.NoError(t, err) require.Equal(t, "admin", created.Cover[0].Role) - _, err = tapper.UpdateHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", *created) + _, err = tapper.UpdateHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", *created, created.Hash) require.NoError(t, err) - require.NoError(t, tapper.DeleteHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work")) + require.NoError(t, tapper.DeleteHubFlight(context.Background(), srv.URL, "tok", "foldwise", "agent-work", created.Hash)) require.Equal(t, "Bearer tok", gotAuth) require.Equal(t, []string{ "GET /api/v1/flights", diff --git a/pkg/tapper/hub_grants.go b/pkg/tapper/hub_grants.go index 1b54ed7e..21b60d8b 100644 --- a/pkg/tapper/hub_grants.go +++ b/pkg/tapper/hub_grants.go @@ -53,19 +53,19 @@ func RevokeGrant(ctx context.Context, hubURL, token, namespace, alias, username return doHubJSON(ctx, http.MethodDelete, hubURL, token, path, nil, nil) } -// SetKegVisibility updates a keg's visibility via PATCH .../settings. visibility +// SetKegVisibility updates a keg's visibility via PATCH .../access. visibility // is public|private. func SetKegVisibility(ctx context.Context, hubURL, token, namespace, alias, visibility string) error { - path := fmt.Sprintf("/api/v1/@%s/kegs/%s/settings", namespace, alias) + path := fmt.Sprintf("/api/v1/@%s/kegs/%s/access", namespace, alias) return doHubJSON(ctx, http.MethodPatch, hubURL, token, path, map[string]string{"visibility": visibility}, nil) } // RenameKeg updates a keg alias in-place within the same namespace via -// PATCH .../settings. The hub keeps the immutable keg id and does not create +// POST .../rename. The hub keeps the immutable keg id and does not create // redirects for the old selector. func RenameKeg(ctx context.Context, hubURL, token, namespace, oldAlias, newAlias string) error { - path := fmt.Sprintf("/api/v1/@%s/kegs/%s/settings", namespace, oldAlias) - return doHubJSON(ctx, http.MethodPatch, hubURL, token, path, map[string]string{"alias": newAlias}, nil) + path := fmt.Sprintf("/api/v1/@%s/kegs/%s/rename", namespace, oldAlias) + return doHubJSON(ctx, http.MethodPost, hubURL, token, path, map[string]string{"alias": newAlias}, nil) } // doHubJSON performs one JSON round-trip against the hub, decoding a non-nil diff --git a/pkg/tapper/hub_kegs.go b/pkg/tapper/hub_kegs.go index de2b6b75..ded49b78 100644 --- a/pkg/tapper/hub_kegs.go +++ b/pkg/tapper/hub_kegs.go @@ -11,7 +11,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -24,28 +23,9 @@ import ( // hub's GET /api/v1/kegs handler (handler.ListUserKegs). const hubKegsPath = "/api/v1/kegs" -const ( - hubOrientPath = "/api/v1/orient" - hubOrientDetailsPath = "/api/v1/orient/details" -) - -// ErrOrientationUnsupported signals that a hub predates the progressive- -// disclosure orientation endpoints. Callers may safely use compatibility -// fallbacks without treating other HTTP failures as feature absence. -var ErrOrientationUnsupported = errors.New("hub orientation API is unavailable") - // HubKeg is one keg the hub reports the authenticated user can reach. It // mirrors the hub's handler.UserKegItem JSON body — keep the two in sync. type HubKeg struct { - Namespace string `json:"namespace"` - Alias string `json:"alias"` - Visibility string `json:"visibility"` - Role string `json:"role"` -} - -// HubOrientationKeg is one compact discovery row returned by a compatible -// Hub. Instructions are intentionally absent. -type HubOrientationKeg struct { Namespace string `json:"namespace"` Alias string `json:"alias"` Title string `json:"title"` @@ -54,15 +34,6 @@ type HubOrientationKeg struct { Role string `json:"role"` } -// HubOrientationDetail is one explicitly requested KEG config projection. -type HubOrientationDetail struct { - Keg string `json:"keg"` - Title string `json:"title"` - Summary string `json:"summary"` - Updated string `json:"updated,omitempty"` - Instructions string `json:"instructions"` -} - // CreateKeg asks the hub to create @namespace/alias via // POST /api/v1/@{namespace}/kegs. A 409 is returned as an error wrapping // keg.ErrExist so callers can detect "already exists" with errors.Is; 401/403 @@ -144,96 +115,6 @@ func ListUserKegs(ctx context.Context, hubURL, token string) ([]HubKeg, error) { return kegs, nil } -// DiscoverOrientationKegs fetches the compact authenticated discovery index. -// A 404 or 405 is reported as ErrOrientationUnsupported so callers can fall -// back to the older /api/v1/kegs surface. -func DiscoverOrientationKegs(ctx context.Context, hubURL, token string) ([]HubOrientationKeg, error) { - base, err := normalizeHubURL(hubURL) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+hubOrientPath, nil) - if err != nil { - return nil, fmt.Errorf("hub: build orientation discovery request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("hub: contact hub: %w", err) - } - defer func() { _ = resp.Body.Close() }() - switch resp.StatusCode { - case http.StatusOK: - case http.StatusNotFound, http.StatusMethodNotAllowed: - return nil, ErrOrientationUnsupported - case http.StatusUnauthorized, http.StatusForbidden: - return nil, fmt.Errorf("hub: %w (%s)", ErrTokenRejected, resp.Status) - default: - return nil, fmt.Errorf("hub: orientation discovery returned %s for %s", resp.Status, hubOrientPath) - } - var out []HubOrientationKeg - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, fmt.Errorf("hub: parse orientation discovery response: %w", err) - } - return out, nil -} - -// FetchOrientationDetails requests targeted guidance for canonical KEG refs. -// The Hub guarantees all-or-nothing authorization and preserves input order. -func FetchOrientationDetails(ctx context.Context, hubURL, token string, refs []string) ([]HubOrientationDetail, error) { - base, err := normalizeHubURL(hubURL) - if err != nil { - return nil, err - } - payload, err := json.Marshal(struct { - Kegs []string `json:"kegs"` - }{Kegs: refs}) - if err != nil { - return nil, fmt.Errorf("hub: encode orientation details request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+hubOrientDetailsPath, bytes.NewReader(payload)) - if err != nil { - return nil, fmt.Errorf("hub: build orientation details request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("hub: contact hub: %w", err) - } - defer func() { _ = resp.Body.Close() }() - switch resp.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - // New Hubs use 404 UNAVAILABLE for an invalid/unauthorized target as - // well as old Hubs for an unknown route. Distinguish by the structured - // code before deciding whether compatibility fallback is safe. - var body struct { - Error string `json:"error"` - Code string `json:"code"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Code == "UNAVAILABLE" { - return nil, errors.New(body.Error) - } - return nil, ErrOrientationUnsupported - case http.StatusMethodNotAllowed: - return nil, ErrOrientationUnsupported - case http.StatusUnauthorized, http.StatusForbidden: - return nil, fmt.Errorf("hub: %w (%s)", ErrTokenRejected, resp.Status) - default: - return nil, fmt.Errorf("hub: orientation details returned %s for %s%s", resp.Status, hubOrientDetailsPath, readHubError(resp)) - } - var out []HubOrientationDetail - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, fmt.Errorf("hub: parse orientation details response: %w", err) - } - return out, nil -} - // readHubError best-effort extracts the hub's {"error": ...} message from a // failed response so the surfaced error carries the hub's own explanation. func readHubError(resp *http.Response) string { diff --git a/pkg/tapper/hub_kegs_test.go b/pkg/tapper/hub_kegs_test.go index 374582d2..8b63d4cb 100644 --- a/pkg/tapper/hub_kegs_test.go +++ b/pkg/tapper/hub_kegs_test.go @@ -71,7 +71,7 @@ func TestListUserKegs_Success(t *testing.T) { require.Equal(t, "/api/v1/kegs", r.URL.Path) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]tapper.HubKeg{ - {Namespace: "jlrickert", Alias: "example", Visibility: "private", Role: "admin"}, + {Namespace: "jlrickert", Alias: "example", Title: "Example", Summary: "Example summary.", Visibility: "private", Role: "admin"}, {Namespace: "shared", Alias: "docs", Visibility: "public", Role: "editor"}, }) })) @@ -83,6 +83,8 @@ func TestListUserKegs_Success(t *testing.T) { require.Len(t, kegs, 2) require.Equal(t, "jlrickert", kegs[0].Namespace) require.Equal(t, "example", kegs[0].Alias) + require.Equal(t, "Example", kegs[0].Title) + require.Equal(t, "Example summary.", kegs[0].Summary) require.Equal(t, "admin", kegs[0].Role) } @@ -99,83 +101,14 @@ func TestListUserKegs_Unauthorized(t *testing.T) { require.True(t, errors.Is(err, tapper.ErrTokenRejected)) } -func TestOrientationEndpoints(t *testing.T) { - t.Parallel() - - var requests []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests = append(requests, r.Method+" "+r.URL.Path) - require.Equal(t, "Bearer tok", r.Header.Get("Authorization")) - switch r.URL.Path { - case "/api/v1/orient": - _ = json.NewEncoder(w).Encode([]tapper.HubOrientationKeg{{ - Namespace: "foldwise", - Alias: "dev", - Title: "Development", - Summary: "Engineering system of record.", - Role: "admin", - }}) - case "/api/v1/orient/details": - var body struct { - Kegs []string `json:"kegs"` - } - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Equal(t, []string{"@foldwise/dev"}, body.Kegs) - _ = json.NewEncoder(w).Encode([]tapper.HubOrientationDetail{{ - Keg: "@foldwise/dev", - Title: "Development", - Summary: "Engineering system of record.", - Updated: "2026-07-29T00:00:00Z", - Instructions: "Operate carefully.", - }}) - default: - http.NotFound(w, r) - } - })) - defer srv.Close() - - discovered, err := tapper.DiscoverOrientationKegs(context.Background(), srv.URL, "tok") - require.NoError(t, err) - require.Equal(t, "Development", discovered[0].Title) - require.Equal(t, "Engineering system of record.", discovered[0].Summary) - - details, err := tapper.FetchOrientationDetails(context.Background(), srv.URL, "tok", []string{"@foldwise/dev"}) - require.NoError(t, err) - require.Equal(t, "Operate carefully.", details[0].Instructions) - require.Equal(t, []string{"GET /api/v1/orient", "POST /api/v1/orient/details"}, requests) -} - -func TestOrientationEndpoints_UnsupportedAndUnavailableAreDistinct(t *testing.T) { - t.Parallel() - - oldHub := httptest.NewServer(http.NotFoundHandler()) - defer oldHub.Close() - _, err := tapper.DiscoverOrientationKegs(context.Background(), oldHub.URL, "tok") - require.ErrorIs(t, err, tapper.ErrOrientationUnsupported) - _, err = tapper.FetchOrientationDetails(context.Background(), oldHub.URL, "tok", []string{"@foldwise/dev"}) - require.ErrorIs(t, err, tapper.ErrOrientationUnsupported) - - newHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "error": "one or more requested kegs are unavailable", - "code": "UNAVAILABLE", - }) - })) - defer newHub.Close() - _, err = tapper.FetchOrientationDetails(context.Background(), newHub.URL, "tok", []string{"@foldwise/dev"}) - require.Error(t, err) - require.NotErrorIs(t, err, tapper.ErrOrientationUnsupported) - require.Contains(t, err.Error(), "unavailable") -} - func TestRenameKeg_Success(t *testing.T) { t.Parallel() - var gotAuth, gotPath string + var gotAuth, gotMethod, gotPath string var gotBody map[string]string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") + gotMethod = r.Method gotPath = r.URL.Path _ = json.NewDecoder(r.Body).Decode(&gotBody) _ = json.NewEncoder(w).Encode(map[string]string{"namespace": "jlrickert", "alias": "renamed"}) @@ -185,6 +118,7 @@ func TestRenameKeg_Success(t *testing.T) { err := tapper.RenameKeg(context.Background(), srv.URL, "tok123", "jlrickert", "example", "renamed") require.NoError(t, err) require.Equal(t, "Bearer tok123", gotAuth) - require.Equal(t, "/api/v1/@jlrickert/kegs/example/settings", gotPath) + require.Equal(t, http.MethodPost, gotMethod) + require.Equal(t, "/api/v1/@jlrickert/kegs/example/rename", gotPath) require.Equal(t, map[string]string{"alias": "renamed"}, gotBody) } diff --git a/pkg/tapper/keg_backend.go b/pkg/tapper/keg_backend.go index 028ff3cb..8f64fa58 100644 --- a/pkg/tapper/keg_backend.go +++ b/pkg/tapper/keg_backend.go @@ -6,18 +6,14 @@ import ( "github.com/jlrickert/tapper/pkg/keg" ) -// KegLocation renders where a keg lives for user-facing output after -// `tap keg create`: "at " for a file-backed keg, "on " for a -// remote keg, or "" when no concrete location is known (in-memory / nil -// target). Unlike KegBackendLabel it intentionally reveals the path/URL so a +// KegLocation renders where a remotely hosted keg lives for user-facing output +// after `tap keg create`: "on ", or "" when no concrete location is +// known. Unlike KegBackendLabel it intentionally reveals the URL so a // fresh create says exactly where the keg landed. func KegLocation(target *keg.Target) string { if target == nil { return "" } - if p := strings.TrimSpace(target.File); p != "" { - return "at " + p - } if hub := strings.TrimSpace(target.HubURL); hub != "" { return "on " + hub } @@ -29,41 +25,26 @@ func KegLocation(target *keg.Target) string { // KegBackendLabel returns a stable, path-free identifier for a keg target // suitable for user-facing output. It is used by surfaces that must describe -// "what kind of keg is this" without leaking the underlying filesystem path, -// remote URL, or other location-revealing details. +// "what kind of keg is this" without leaking the remote URL or other +// location-revealing details. // // Mapping by scheme: // -// - file-backed: "file-backed" // - hub: "keg:@/" // - http(s): "http" or "https" -// - in-memory: "in-memory" // - other/unknown: the scheme string, or "" when target is nil // // The hub label is the canonical keg reference (Target.String): the real "keg" // scheme with the hub resolved from the namespace, never encoded in the string. -// File-backed kegs intentionally collapse to a single token: the alias is the -// user-visible handle, and the path lives only behind `tap info`. func KegBackendLabel(target *keg.Target) string { if target == nil { return "" } - // Memory targets do not surface through Scheme() because NewMemory - // leaves every string field blank — Scheme() falls through to - // SchemeFile in that case. Check the explicit Memory flag first so - // in-memory kegs render correctly even before any persistence work. - if target.Memory { - return "in-memory" - } switch target.Scheme() { - case keg.SchemeFile: - return "file-backed" case keg.SchemeAlias: // A keg reference renders with the real "keg" scheme; the hub is // resolution metadata, not part of the reference. Mirror Target.String(). return target.String() - case keg.SchemeMemory: - return "in-memory" case keg.SchemeHTTP: return "http" case keg.SchemeHTTPs: diff --git a/pkg/tapper/keg_backend_test.go b/pkg/tapper/keg_backend_test.go index e76762f7..9f491c35 100644 --- a/pkg/tapper/keg_backend_test.go +++ b/pkg/tapper/keg_backend_test.go @@ -17,12 +17,6 @@ func TestKegBackendLabel(t *testing.T) { require.Equal(t, "", tapper.KegBackendLabel(nil)) }) - t.Run("file_target_collapses_to_file_backed", func(t *testing.T) { - t.Parallel() - target := keg.NewFile("/home/testuser/Documents/kegs/notes") - require.Equal(t, "file-backed", tapper.KegBackendLabel(&target)) - }) - t.Run("hub_target_renders_canonical_keg_ref", func(t *testing.T) { t.Parallel() // The hub ("knut") is resolution metadata, not part of the reference: @@ -31,12 +25,6 @@ func TestKegBackendLabel(t *testing.T) { require.Equal(t, "keg:@alice/blog", tapper.KegBackendLabel(&target)) }) - t.Run("memory_target_collapses_to_in_memory", func(t *testing.T) { - t.Parallel() - target := keg.NewMemory("scratch") - require.Equal(t, "in-memory", tapper.KegBackendLabel(&target)) - }) - t.Run("http_target_returns_scheme_only", func(t *testing.T) { t.Parallel() target, err := keg.Parse("https://example.com/kegs/blog") diff --git a/pkg/tapper/keg_service.go b/pkg/tapper/keg_service.go index b6cdd8ae..ec3c8eec 100644 --- a/pkg/tapper/keg_service.go +++ b/pkg/tapper/keg_service.go @@ -3,86 +3,45 @@ package tapper import ( "context" "fmt" - "path/filepath" "strings" "sync" - appCtx "github.com/jlrickert/cli-toolkit/appctx" "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" ) -// KegService resolves keg targets from config, project paths, and explicit filesystem locations. +// KegService resolves configured remote KEGs. type KegService struct { - // Runtime provides filesystem and environment access used to resolve kegs. - Runtime *toolkit.Runtime - - // ConfigService resolves configured keg aliases and targets. + Runtime *toolkit.Runtime ConfigService *ConfigService - // cacheMu guards kegCache for concurrent access. - cacheMu sync.Mutex - // kegCache memoizes resolved kegs by alias or file-derived cache key. + cacheMu sync.Mutex kegCache map[string]keg.Keg - // authStoreOnce guards the lazy load of authStore. We only touch the - // auth file on first remote-keg resolution so local-only workflows - // never pay for a disk read they don't need. authStoreOnce sync.Once - // authStore is the loaded auth store, or nil when the file is missing - // or failed to parse. Nil is valid: the resolver short-circuits to "". - authStore *AuthStore - // authStorePath is the path authStore was loaded from, handed to the - // resolver so it can persist a refreshed token back to disk. + authStore *AuthStore authStorePath string - // authResolver is the single resolver instance built alongside the - // store load. One instance per service matters: the resolver's mutex - // serializes token refresh, which only works when every resolution - // shares the same resolver (a per-call instance would give each caller - // its own lock and let concurrent resolves double-spend the single-use - // refresh token). - authResolver keg.TokenResolver + authResolver keg.TokenResolver } -// ResolveKegOptions controls how KegService resolves a keg target. +// ResolveKegOptions controls remote KEG resolution. type ResolveKegOptions struct { - // Root is the base path used for project and fallback resolution. - Root string - // Keg is the explicit keg alias to resolve. - Keg string - // Namespace overrides the namespace component of the resolved reference when - // the selector is a bare name. Empty uses the configured chain. + // Root is used only for workspace kegMap matching. + Root string + Keg string Namespace string - // Hub pins the hub the reference resolves on, overriding namespace→hub - // resolution. Empty resolves the hub from the namespace as usual. - Hub string - // Project resolves a keg from project-local locations. - Project bool - // Cwd limits project resolution to the current working directory. - Cwd bool - // Path resolves a keg from an explicit filesystem path. - Path string - // RequireBootstrap makes config/namespace/hub-driven resolution fail with - // ErrNotBootstrapped when no user config exists (`tap bootstrap` has not been - // run). The full `tap` surface sets it; the pruned `keg` binary does not. - // Explicit filesystem destinations (Project/Cwd/Path) and selectors that are - // themselves a filesystem path are exempt. + Hub string + RequireBootstrap bool - // NoCache disables in-memory keg caching for this resolution. - NoCache bool + NoCache bool } -// ensureCache initializes the in-memory keg cache when needed. func (s *KegService) ensureCache() { if s.kegCache == nil { s.kegCache = map[string]keg.Keg{} } } -// tokenResolver returns a keg.TokenResolver backed by the service's lazily -// loaded AuthStore. Load failures are swallowed (logged at debug) and yield -// a nil-backed resolver that always returns "" — a missing or corrupt auth -// file must never block keg resolution for local or token-pinned targets. func (s *KegService) tokenResolver() keg.TokenResolver { s.authStoreOnce.Do(func() { defer func() { @@ -105,301 +64,55 @@ func (s *KegService) tokenResolver() keg.TokenResolver { return s.authResolver } -// Resolve returns a keg using explicit path, project, alias, or configured fallback resolution. -func (s *KegService) Resolve(ctx context.Context, opts ResolveKegOptions) (keg.Keg, error) { +// Resolve returns a RemoteKeg selected by an explicit reference or config. +func (s *KegService) Resolve(ctx context.Context, options ResolveKegOptions) (keg.Keg, error) { s.cacheMu.Lock() defer s.cacheMu.Unlock() s.ensureCache() - cache := !opts.NoCache - - alias := strings.TrimSpace(opts.Keg) - explicitPath := strings.TrimSpace(opts.Path) - if alias != "" && (opts.Project || opts.Cwd || explicitPath != "") { - return nil, fmt.Errorf("--keg cannot be used with --project, --cwd, or --path") + if options.RequireBootstrap && !s.ConfigService.UserConfigExists() { + return nil, ErrNotBootstrapped } - if opts.Project && explicitPath != "" { - return nil, fmt.Errorf("--project cannot be used with --path") - } - - base := strings.TrimSpace(opts.Root) - if base == "" { + root := strings.TrimSpace(options.Root) + if root == "" { var err error - base, err = s.Runtime.Getwd() + root, err = s.Runtime.Getwd() if err != nil { return nil, fmt.Errorf("failed to get working directory: %w", err) } } - - if explicitPath != "" { - return s.resolveProjectTarget(ctx, explicitPath, cache) - } - if opts.Project || opts.Cwd { - if !opts.Cwd { - if gitRoot := appCtx.FindGitRoot(ctx, s.Runtime, base); gitRoot != "" { - base = gitRoot - } + selector := strings.TrimSpace(options.Keg) + if selector == "" { + cfg, err := s.ConfigService.Config() + if err != nil { + return nil, fmt.Errorf("failed to resolve workspace config: %w", err) } - return s.resolveProjectTarget(ctx, base, cache) - } - - // Everything below resolves through config (namespace/hub chains). On the - // full `tap` surface this requires `tap bootstrap` to have run; a selector - // that is itself a filesystem path is exempt (it needs no config). The - // explicit-path / project / cwd branches above are never gated. - if opts.RequireBootstrap && !s.ConfigService.UserConfigExists() { - if alias == "" || parseKegRef(alias).Path == "" { - return nil, ErrNotBootstrapped + selector = cfg.DefaultKeg() + if selector == "" { + selector = cfg.LookupAlias(s.Runtime, root) } - } - - if alias != "" { - return s.resolveKegAlias(ctx, alias, opts.Namespace, opts.Hub, base, cache) - } - - return s.resolvePath(ctx, base, opts.Namespace, opts.Hub, cache) -} - -// resolveProjectTarget resolves a filesystem-backed keg under known project keg locations. -func (s *KegService) resolveProjectTarget(ctx context.Context, base string, cache bool) (keg.Keg, error) { - rawBase := filepath.Clean(toolkit.ExpandEnv(s.Runtime, base)) - expandedBase := rawBase - if p, err := toolkit.ExpandPath(s.Runtime, rawBase); err == nil { - expandedBase = filepath.Clean(p) - } - - // Check whether the base directory itself exists before searching for keg files. - info, statErr := s.Runtime.Stat(expandedBase, false) - if statErr != nil || !info.IsDir() { - return nil, &PathNotFoundError{Path: base} - } - - baseCandidates := []string{rawBase} - if expandedBase != "" && expandedBase != rawBase { - baseCandidates = append(baseCandidates, expandedBase) - } - - var candidates []string - seen := map[string]struct{}{} - for _, b := range baseCandidates { - if b == "" { - continue - } - baseName := filepath.Base(filepath.Clean(b)) - for _, candidate := range []string{ - b, - filepath.Join(b, "kegs", baseName), - filepath.Join(b, "kegs", "project"), - filepath.Join(b, "kegs", "tapper"), - } { - candidate = filepath.Clean(candidate) - if _, ok := seen[candidate]; ok { - continue - } - seen[candidate] = struct{}{} - candidates = append(candidates, candidate) + if selector == "" { + selector = cfg.FallbackKeg() } - } - - var checked []string - for _, candidate := range candidates { - if candidate == "" { - continue - } - kegFile := filepath.Join(candidate, "keg") - checked = append(checked, kegFile) - info, statErr := s.Runtime.Stat(kegFile, false) - if statErr != nil || !info.Mode().IsRegular() { - continue - } - return s.resolveFileKeg(ctx, candidate, cache) + if selector == "" { + return nil, fmt.Errorf("no KEG configured") } - return nil, newProjectKegNotFoundError(checked) -} - -// resolveFileKeg resolves a keg from a filesystem root and caches it by normalized path. -// Symlinks are resolved before generating the cache key so that symlinks or -// mounts pointing to the same underlying directory share a single cache entry. -func (s *KegService) resolveFileKeg(ctx context.Context, root string, cache bool) (keg.Keg, error) { - cleanRoot := filepath.Clean(root) - // Resolve symlinks so different paths that point to the same physical - // directory produce identical cache keys. - if resolved, err := filepath.EvalSymlinks(cleanRoot); err == nil { - cleanRoot = resolved - } - key := "file:" + cleanRoot - if cache && s.kegCache[key] != nil { - return s.kegCache[key], nil + cacheKey := selector + "\x00" + options.Namespace + "\x00" + options.Hub + if !options.NoCache && s.kegCache[cacheKey] != nil { + return s.kegCache[cacheKey], nil } - - target := keg.NewFile(root) - k, err := keg.NewKegFromTarget(ctx, target, s.Runtime, keg.WithTokenResolver(s.tokenResolver())) + target, err := s.ConfigService.ResolveTarget(selector, options.Namespace, options.Hub) if err != nil { return nil, err } - - if cache { - s.kegCache[key] = k - } - return k, nil -} - -// resolvePath resolves the effective keg alias from config for the given path and returns its keg. -// -// Precedence: defaultKeg (authoritative, project-set) → kegMap (path-specific) -// → fallbackKeg (global-user last resort). The default* slots are meant for -// project config and win first; kegMap routes by path; fallback* are what -// `tap bootstrap` writes for the global user so anything more specific overrides. -func (s *KegService) resolvePath(ctx context.Context, path, nsOverride, hubOverride string, cache bool) (keg.Keg, error) { - s.ensureCache() - cfg, err := s.ConfigService.Config() - if err != nil { - return nil, fmt.Errorf("failed to resolve path config: %w", err) - } - kegAlias := cfg.DefaultKeg() - if kegAlias == "" { - kegAlias = cfg.LookupAlias(s.Runtime, path) - } - if kegAlias == "" { - kegAlias = cfg.FallbackKeg() - } - if kegAlias == "" { - return nil, fmt.Errorf("no keg configured") - } - return s.resolveKegAlias(ctx, kegAlias, nsOverride, hubOverride, path, cache) -} - -// resolveKegAlias resolves a keg selector from config and falls back to -// project-local resolution. The selector is a keg reference string (a bare -// name, @ns/name, keg:..., or a path), resolved via the namespace-centric -// chain in ConfigService.ResolveTarget. When no configured hub/namespace is -// steering that bare name, a project-local keg at /kegs/ answers -// instead, so local project kegs work without any config. A configured remote -// hub that is missing a namespace must surface as an error rather than being -// masked by a local keg of the same name. -func (s *KegService) resolveKegAlias(ctx context.Context, kegAlias, nsOverride, hubOverride string, projectRoot string, cache bool) (keg.Keg, error) { - s.ensureCache() - if kegAlias == "" { - return nil, fmt.Errorf("no keg configured") - } - // The namespace/hub overrides change the resolved target, so they must be - // part of the cache key — otherwise `--namespace a` and `--namespace b` - // would collide on the same bare alias. - cacheKey := kegAlias - if nsOverride != "" || hubOverride != "" { - cacheKey = kegAlias + "\x00" + nsOverride + "\x00" + hubOverride - } - if cache && s.kegCache[cacheKey] != nil { - return s.kegCache[cacheKey], nil - } - - target, err := s.ConfigService.ResolveTarget(kegAlias, nsOverride, hubOverride) - if err == nil && target != nil { - k, kerr := keg.NewKegFromTarget(ctx, *target, s.Runtime, keg.WithTokenResolver(s.tokenResolver())) - if kerr != nil { - return k, kerr - } - if k != nil { - s.kegCache[cacheKey] = k - } - return k, nil - } - - // ResolveTarget could not turn the selector into a target. A bare keg name - // (no namespace, hub, or path) may instead name a project-local keg at - // /kegs/ — resolve it so local project kegs work without - // requiring any config entries. - if ref := parseKegRef(kegAlias); ref.Name != "" && ref.Namespace == "" && ref.Hub == "" && ref.Path == "" && s.allowProjectAliasFallback() { - if projectKeg, found, projectErr := s.resolveProjectAlias(ctx, projectRoot, ref.Name, cache); projectErr != nil { - return nil, projectErr - } else if found { - if cache && projectKeg != nil { - s.kegCache[kegAlias] = projectKeg - } - return projectKeg, nil - } - } - - // ResolveTarget failed and no project-local fallback was found. + resolved, err := keg.NewKegFromTarget(ctx, *target, s.Runtime, keg.WithTokenResolver(s.tokenResolver())) if err != nil { return nil, err } - - return nil, fmt.Errorf("keg %q could not be resolved", kegAlias) -} - -func (s *KegService) allowProjectAliasFallback() bool { - if s.ConfigService == nil { - return true - } - cfg, err := s.ConfigService.Config() - if err != nil || cfg == nil { - return true - } - if strings.TrimSpace(cfg.DefaultHub()) != "" || - strings.TrimSpace(cfg.FallbackHub()) != "" || - strings.TrimSpace(cfg.DefaultNamespace()) != "" || - strings.TrimSpace(cfg.FallbackNamespace()) != "" || - len(cfg.Namespaces()) > 0 { - return false + if !options.NoCache { + s.kegCache[cacheKey] = resolved } - for _, entry := range cfg.Hubs() { - if strings.TrimSpace(entry.DefaultNamespace) != "" { - return false - } - } - return true -} - -// resolveProjectAlias resolves a project-local alias at /kegs//keg when present. -func (s *KegService) resolveProjectAlias(ctx context.Context, base string, alias string, cache bool) (keg.Keg, bool, error) { - base = strings.TrimSpace(base) - alias = strings.TrimSpace(alias) - if base == "" || alias == "" { - return nil, false, nil - } - - searchBase := base - if gitRoot := appCtx.FindGitRoot(ctx, s.Runtime, base); gitRoot != "" { - searchBase = gitRoot - } - - rawBase := filepath.Clean(toolkit.ExpandEnv(s.Runtime, searchBase)) - expandedBase := rawBase - if p, err := toolkit.ExpandPath(s.Runtime, rawBase); err == nil { - expandedBase = filepath.Clean(p) - } - - baseCandidates := []string{rawBase} - if expandedBase != "" && expandedBase != rawBase { - baseCandidates = append(baseCandidates, expandedBase) - } - - seen := map[string]struct{}{} - for _, candidateBase := range baseCandidates { - if candidateBase == "" { - continue - } - projectKegRoot := filepath.Clean(filepath.Join(candidateBase, "kegs", alias)) - if _, ok := seen[projectKegRoot]; ok { - continue - } - seen[projectKegRoot] = struct{}{} - - kegFile := filepath.Join(projectKegRoot, "keg") - info, statErr := s.Runtime.Stat(kegFile, false) - if statErr != nil || !info.Mode().IsRegular() { - continue - } - - k, err := s.resolveFileKeg(ctx, projectKegRoot, cache) - if err != nil { - return nil, false, err - } - return k, true, nil - } - - return nil, false, nil + return resolved, nil } diff --git a/pkg/tapper/keg_service_resolver_test.go b/pkg/tapper/keg_service_resolver_test.go deleted file mode 100644 index 11941d90..00000000 --- a/pkg/tapper/keg_service_resolver_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package tapper_test - -import ( - "context" - "path/filepath" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// TestKegService_QueryResolver_FavoriteIndex verifies that kegs resolved -// through KegService have the query resolver injected so that key=value -// attribute predicates (e.g. "favorite=true") work in config-driven custom -// indexes. This is the integration test for the resolver wiring fix. -func TestKegService_QueryResolver_FavoriteIndex(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t, sandbox.WithFixture("example", "/home/testuser")) - root := "/home/testuser/work" - require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - // Write user config with a local hub; the bare name "test" resolves to - // @local/test under the hub's basePath. - userCfg := `fallbackKeg: test -fallbackNamespace: local -hubs: - home: - kind: local - basePath: /home/testuser/kegs -` - require.NoError(t, fx.Runtime().Mkdir(tap.PathService.ConfigRoot, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - - // Create and init the keg directly (Init writes a proper config file). - kegDir := "/home/testuser/kegs/@local/test" - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - initKeg, err := keg.NewKegFromTarget(fx.Context(), keg.NewFile(kegDir), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, initKeg.Init(fx.Context())) - makeKegNonStrict(t, fx.Context(), initKeg) - - // Add the "favorite" custom index to the keg config. - require.NoError(t, keg.UpdateConfig(fx.Context(), initKeg, func(cfg *keg.Config) { - cfg.Indexes = append(cfg.Indexes, keg.IndexEntry{ - File: "favorite", - Summary: "favorite nodes", - Query: "favorite=true", - }) - })) - - ctx := context.Background() - - // Create nodes through the Tap API, which resolves via KegService - // and triggers injectDexOpts. - favID, err := tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "test"}, - Title: "My Favorite Node", - Attrs: map[string]string{"favorite": "true"}, - }) - require.NoError(t, err) - - _, err = tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "test"}, - Title: "Regular Node", - }) - require.NoError(t, err) - - yesID, err := tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "test"}, - Title: "Also Favorite", - Attrs: map[string]string{"favorite": "yes"}, - }) - require.NoError(t, err) - - // Rebuild all indexes. This goes through Tap -> KegService -> Keg.Index, - // which should now have the query resolver injected. - _, err = tap.Index(ctx, tapper.IndexOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "test"}, - }) - require.NoError(t, err) - - // Read the custom "favorite" index. - content, err := tap.IndexCat(ctx, tapper.IndexCatOptions{ - KegTargetOptions: tapper.KegTargetOptions{Keg: "test"}, - Name: "favorite", - }) - require.NoError(t, err, "favorite index should exist after reindex") - require.NotEmpty(t, content, "favorite index should not be empty") - - // The node with favorite=true should be present. - require.Contains(t, content, "My Favorite Node", - "node with favorite=true should appear in the favorite index") - require.Contains(t, content, favID.String(), - "favorite node ID should appear in the favorite index") - - // The node without favorite attr should NOT be present. - require.NotContains(t, content, "Regular Node", - "node without favorite attr should not appear in the favorite index") - - // The node with favorite=yes should NOT match favorite=true query. - require.NotContains(t, content, "Also Favorite", - "node with favorite=yes should not match favorite=true query") - _ = yesID // used above in assertions -} - -// TestKegService_QueryResolver_ProjectKeg verifies the resolver is injected -// for project-local kegs resolved via --project / --path. -func TestKegService_QueryResolver_ProjectKeg(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t, sandbox.WithFixture("example", "/home/testuser")) - - // Set up a project-local keg at /kegs// - root := "/home/testuser/myproject" - kegDir := filepath.Join(root, "kegs", "myproject") - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - // Write minimal user config (no keg aliases -- project resolution only). - require.NoError(t, fx.Runtime().Mkdir(tap.PathService.ConfigRoot, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile( - tap.PathService.UserConfig(), - []byte("kegs: {}\ndefaultHub: \"\"\n"), - 0o644, - )) - - // Init the keg. - initKeg, err := keg.NewKegFromTarget(fx.Context(), keg.NewFile(kegDir), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, initKeg.Init(fx.Context())) - makeKegNonStrict(t, fx.Context(), initKeg) - - // Add "pinned" custom index. - require.NoError(t, keg.UpdateConfig(fx.Context(), initKeg, func(cfg *keg.Config) { - cfg.Indexes = append(cfg.Indexes, keg.IndexEntry{ - File: "pinned", - Summary: "pinned notes", - Query: "pinned=yes", - }) - })) - - ctx := context.Background() - - // Create nodes through Tap with explicit path. - _, err = tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Path: kegDir}, - Title: "Pinned Note", - Attrs: map[string]string{"pinned": "yes"}, - }) - require.NoError(t, err) - - _, err = tap.Create(ctx, tapper.CreateOptions{ - KegTargetOptions: tapper.KegTargetOptions{Path: kegDir}, - Title: "Unpinned Note", - }) - require.NoError(t, err) - - // Rebuild indexes. - _, err = tap.Index(ctx, tapper.IndexOptions{ - KegTargetOptions: tapper.KegTargetOptions{Path: kegDir}, - }) - require.NoError(t, err) - - // Verify the custom index. - content, err := tap.IndexCat(ctx, tapper.IndexCatOptions{ - KegTargetOptions: tapper.KegTargetOptions{Path: kegDir}, - Name: "pinned", - }) - require.NoError(t, err, "pinned index should exist") - require.Contains(t, content, "Pinned Note") - require.NotContains(t, content, "Unpinned Note") -} diff --git a/pkg/tapper/keg_service_test.go b/pkg/tapper/keg_service_test.go index 13430280..491e4f2d 100644 --- a/pkg/tapper/keg_service_test.go +++ b/pkg/tapper/keg_service_test.go @@ -10,250 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -// kegsBlock declares a single local hub rooted at ~/Documents/kegs and sets -// the fallback namespace to local, so a bare keg name N resolves to the local -// keg @local/N on disk at ~/Documents/kegs/@local/N. Tests create only the -// directories they resolve; unreferenced names are harmless. -const kegsBlock = `fallbackNamespace: local -hubs: - home: - kind: local - basePath: ~/Documents/kegs -` - -func TestResolve_DefaultKegOverridesKegMap(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t, sandbox.WithFixture("example", "/home/testuser")) - root := "/home/testuser/repos/github.com/jlrickert/tapper" - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - userCfg := []byte(`fallbackKeg: fallback -kegMap: - - alias: pub - pathPrefix: ~/repos/github.com -` + kegsBlock) - projectCfg := []byte(`defaultKeg: work -kegMap: [] -kegs: {} -`) - - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.UserConfig()), 0o755, true)) - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.ProjectConfig()), 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), userCfg, 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.ProjectConfig(), projectCfg, 0o644)) - - require.NoError(t, fx.Runtime().Mkdir("/home/testuser/Documents/kegs/@local/pub", 0o755, true)) - require.NoError(t, fx.Runtime().Mkdir("/home/testuser/Documents/kegs/@local/work", 0o755, true)) - require.NoError(t, fx.Runtime().Mkdir("/home/testuser/Documents/kegs/@local/fallback", 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/Documents/kegs/@local/pub/keg", []byte(""), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/Documents/kegs/@local/work/keg", []byte(""), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/Documents/kegs/@local/fallback/keg", []byte(""), 0o644)) - - // defaultKeg is authoritative and wins over a matching kegMap rule - // (precedence: defaultKeg → kegMap → fallbackKeg). - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: root, - }) - require.NoError(t, err) - require.NotNil(t, k) - require.NotNil(t, k.Target) - require.Equal(t, filepath.Clean("/home/testuser/Documents/kegs/@local/work"), filepath.Clean(k.Target().Path())) -} - -func TestResolve_FullPrecedenceChain(t *testing.T) { - t.Parallel() - - newTap := func(innerT *testing.T) (*sandbox.Sandbox, *tapper.Tap, string) { - innerT.Helper() - fx := NewSandbox(innerT, sandbox.WithFixture("example", "/home/testuser")) - root := "/home/testuser/repos/github.com/jlrickert/tapper" - require.NoError(innerT, fx.Setwd(root)) - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(innerT, err) - require.NoError(innerT, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.UserConfig()), 0o755, true)) - require.NoError(innerT, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.ProjectConfig()), 0o755, true)) - return fx, tap, root - } - - writeCfg := func(innerT *testing.T, fx *sandbox.Sandbox, tap *tapper.Tap, userCfg string, projectCfg string) { - innerT.Helper() - require.NoError(innerT, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - require.NoError(innerT, fx.Runtime().AtomicWriteFile(tap.PathService.ProjectConfig(), []byte(projectCfg), 0o644)) - } - - mkKegs := func(innerT *testing.T, fx *sandbox.Sandbox, aliases ...string) { - innerT.Helper() - for _, alias := range aliases { - require.NoError(innerT, fx.Runtime().Mkdir(filepath.Join("/home/testuser/Documents/kegs/@local", alias), 0o755, true)) - require.NoError(innerT, fx.Runtime().AtomicWriteFile(filepath.Join("/home/testuser/Documents/kegs/@local", alias, "keg"), []byte(""), 0o644)) - } - } - - t.Run("explicit_alias_wins", func(innerT *testing.T) { - innerT.Parallel() - fx, tap, root := newTap(innerT) - - writeCfg(innerT, fx, tap, `fallbackKeg: pub -kegMap: - - alias: pub - pathPrefix: ~/repos/github.com -`+kegsBlock, `defaultKeg: work -kegMap: [] -kegs: {} -`) - - mkKegs(innerT, fx, "pub", "work", "explicit") - - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: root, - Keg: "explicit", - }) - require.NoError(innerT, err) - require.Equal(innerT, filepath.Clean("/home/testuser/Documents/kegs/@local/explicit"), filepath.Clean(k.Target().Path())) - }) - - t.Run("default_wins_over_map_when_path_matches", func(innerT *testing.T) { - innerT.Parallel() - fx, tap, root := newTap(innerT) - - writeCfg(innerT, fx, tap, `fallbackKeg: fallback -kegMap: - - alias: pub - pathPrefix: ~/repos/github.com -`+kegsBlock, `defaultKeg: work -kegMap: [] -kegs: {} -`) - - mkKegs(innerT, fx, "pub", "work", "fallback") - - // defaultKeg is authoritative and wins even though the kegMap rule also - // matches the path (precedence: defaultKeg → kegMap → fallbackKeg). - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: root, - }) - require.NoError(innerT, err) - require.Equal(innerT, filepath.Clean("/home/testuser/Documents/kegs/@local/work"), filepath.Clean(k.Target().Path())) - }) - - t.Run("default_used_when_map_does_not_match", func(innerT *testing.T) { - innerT.Parallel() - fx, tap, _ := newTap(innerT) - - writeCfg(innerT, fx, tap, `fallbackKeg: fallback -kegMap: - - alias: pub - pathPrefix: ~/repos/gitlab.com -`+kegsBlock, `defaultKeg: work -kegMap: [] -kegs: {} -`) - - mkKegs(innerT, fx, "pub", "work", "fallback") - - // kegMap does NOT match (gitlab.com vs github.com), so defaultKeg wins. - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: "/home/testuser/repos/github.com/jlrickert/tapper", - }) - require.NoError(innerT, err) - require.Equal(innerT, filepath.Clean("/home/testuser/Documents/kegs/@local/work"), filepath.Clean(k.Target().Path())) - }) - - t.Run("map_used_when_default_empty", func(innerT *testing.T) { - innerT.Parallel() - fx, tap, root := newTap(innerT) - - writeCfg(innerT, fx, tap, `fallbackKeg: fallback -kegMap: - - alias: pub - pathPrefix: ~/repos/github.com -`+kegsBlock, `kegMap: [] -kegs: {} -`) - - mkKegs(innerT, fx, "pub", "fallback") - - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: root, - }) - require.NoError(innerT, err) - require.Equal(innerT, filepath.Clean("/home/testuser/Documents/kegs/@local/pub"), filepath.Clean(k.Target().Path())) - }) - - t.Run("fallback_used_when_default_and_map_missing", func(innerT *testing.T) { - innerT.Parallel() - fx, tap, _ := newTap(innerT) - - writeCfg(innerT, fx, tap, `fallbackKeg: fallback -kegMap: [] -`+kegsBlock, `kegMap: [] -kegs: {} -`) - - mkKegs(innerT, fx, "fallback") - - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: "/home/testuser/unmapped/workspace", - }) - require.NoError(innerT, err) - require.Equal(innerT, filepath.Clean("/home/testuser/Documents/kegs/@local/fallback"), filepath.Clean(k.Target().Path())) - }) -} - -func TestResolve_KegMapMissFallsToDefaultThenFallback(t *testing.T) { - t.Parallel() - - fx := NewSandbox(t, sandbox.WithFixture("example", "/home/testuser")) - root := "/home/testuser/repos/github.com/work-devel/project.202602" - require.NoError(t, fx.Setwd(root)) - require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.UserConfig()), 0o755, true)) - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.ProjectConfig()), 0o755, true)) - - // kegMap points to a prefix that does NOT match the working directory. - // defaultKeg is set, so it should be used when kegMap misses. - userCfg := `fallbackKeg: pub -defaultKeg: dev -kegMap: - - alias: work - pathPrefix: ~/sandbox/work/ -` + kegsBlock - projectCfg := `kegMap: [] -kegs: {} -` - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.ProjectConfig(), []byte(projectCfg), 0o644)) - - for _, alias := range []string{"pub", "work", "dev"} { - require.NoError(t, fx.Runtime().Mkdir(filepath.Join("/home/testuser/Documents/kegs/@local", alias), 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(filepath.Join("/home/testuser/Documents/kegs/@local", alias, "keg"), []byte(""), 0o644)) - } - - k, err := tap.KegService.Resolve(context.Background(), tapper.ResolveKegOptions{ - Root: root, - }) - require.NoError(t, err) - // kegMap misses, so defaultKeg ("dev") should be used, NOT fallbackKeg ("pub"). - require.Equal(t, filepath.Clean("/home/testuser/Documents/kegs/@local/dev"), filepath.Clean(k.Target().Path())) -} - func TestResolve_RemoteFallbackHubWithoutNamespaceDoesNotFallBackProjectAlias(t *testing.T) { t.Parallel() diff --git a/pkg/tapper/node_exists.go b/pkg/tapper/node_exists.go index 73783ab8..15a33072 100644 --- a/pkg/tapper/node_exists.go +++ b/pkg/tapper/node_exists.go @@ -9,7 +9,7 @@ import ( ) // describeKeg renders a keg's identity for error messages: its canonical -// reference (keg:@ns/name, or a file path) and, for a remote keg, the hub URL it +// reference (keg:@ns/name) and the hub URL it // reads from. It lets an otherwise opaque "node N not found" name the hub, // namespace, and keg that were actually consulted. Returns a generic phrase when // the keg has no resolved target (e.g. an in-memory keg in tests). @@ -24,20 +24,7 @@ func describeKeg(k keg.Keg) string { return ref } -// nodeExistsWithContent reports whether the given node is a real node with -// content (README.md present), as opposed to a bare shadow-reservation -// directory left behind by FsRepo.Next() or FsRepo.WithNodeLock(). -// -// Repository.HasNode returns true for any existing directory, which is -// unsuitable as a load-bearing existence gate at the Tap layer: an empty -// directory produced as a lock or allocation artifact would pass. Callers -// that need to authenticate against a fully-written node must use this -// helper instead. -// -// The check is performed by attempting to read the node's content file; if -// the repository reports ErrNotExist (either because the directory is -// missing or because README.md has not been written), the node is reported -// as absent and no error is returned. +// nodeExistsWithContent reports whether the Hub exposes a complete node. // // nodeExistsWithContent does NOT hold any node lock — it is safe to call // from pre-lock gates. The authoritative under-lock check lives in diff --git a/pkg/tapper/node_ref_arg_test.go b/pkg/tapper/node_ref_arg_test.go deleted file mode 100644 index 1c136ee0..00000000 --- a/pkg/tapper/node_ref_arg_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package tapper_test - -import ( - "path/filepath" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/stretchr/testify/require" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" -) - -// twoLocalKegs materializes a local filesystem hub with two kegs laid out at -// /@local/. The hub's default namespace is local, so a bare keg -// name N resolves to @local/N. Each keg gets one node whose body names the keg, -// so a reader can tell which keg a node argument actually resolved to. -// "current" is the keg a command resolves for a bare id (via --keg current); -// "other" is the redirect target a cross-keg ref must reach. -// -// Returns the Tap plus the node ids created in each keg. -func twoLocalKegs(t *testing.T, fx *sandbox.Sandbox) (tap *tapper.Tap, currentID, otherID keg.NodeId) { - t.Helper() - rt := fx.Runtime() - ctx := fx.Context() - - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: rt}) - require.NoError(t, err) - - basePath := filepath.Join(fx.GetJail(), "kegs") - userCfg := `defaultKeg: current -hubs: - home: - kind: local - defaultNamespace: local - basePath: ` + basePath + ` -` - require.NoError(t, rt.Mkdir(tap.PathService.ConfigRoot, 0o755, true)) - require.NoError(t, rt.AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - - makeNode := func(name, body string) keg.NodeId { - dir := filepath.Join(basePath, "@local", name) - require.NoError(t, rt.Mkdir(dir, 0o755, true)) - k, err := keg.NewKegFromTarget(ctx, keg.NewFile(dir), rt) - require.NoError(t, err) - require.NoError(t, k.Init(ctx)) - makeKegNonStrict(t, ctx, k) - id, err := k.Create(ctx, &keg.CreateOptions{Body: []byte(body)}) - require.NoError(t, err) - return id.ID - } - - currentID = makeNode("current", "I live in the CURRENT keg.\n") - otherID = makeNode("other", "I live in the OTHER keg.\n") - return tap, currentID, otherID -} - -// TestResolveNodeArg_QualifiedRefRedirectsToOtherKeg verifies that a -// "keg:@local//" argument passed to cat operates on the named keg, not -// the --keg-resolved current keg. This is the redirect through ResolveNodeRef. -func TestResolveNodeArg_QualifiedRefRedirectsToOtherKeg(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap, _, otherID := twoLocalKegs(t, fx) - - out, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{"keg:@local/other/" + otherID.PathNumeric()}, - KegTargetOptions: tapper.KegTargetOptions{Keg: "current"}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, out, "OTHER keg") - require.NotContains(t, out, "CURRENT keg") -} - -// TestResolveNodeArg_AliasRefRedirectsToOtherKeg verifies that a -// "keg:/" argument resolves the alias through the tap-config kegs map -// and operates on that keg rather than the current keg. -func TestResolveNodeArg_AliasRefRedirectsToOtherKeg(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap, _, otherID := twoLocalKegs(t, fx) - - out, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{"keg:other/" + otherID.PathNumeric()}, - KegTargetOptions: tapper.KegTargetOptions{Keg: "current"}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, out, "OTHER keg") - require.NotContains(t, out, "CURRENT keg") -} - -// TestResolveNodeArg_BareIDStaysOnCurrentKeg pins the unchanged behavior: a bare -// id reads from the resolved current keg, never the other keg. -func TestResolveNodeArg_BareIDStaysOnCurrentKeg(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap, currentID, _ := twoLocalKegs(t, fx) - - out, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{currentID.PathNumeric()}, - KegTargetOptions: tapper.KegTargetOptions{Keg: "current"}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, out, "CURRENT keg") - require.NotContains(t, out, "OTHER keg") -} - -// TestResolveNodeArg_StatsRedirects checks that the redirect is wired at the Tap -// layer broadly, not just in cat: Stats on a qualified ref must read the other -// keg's node (and not error as if the id were missing in the current keg). -func TestResolveNodeArg_StatsRedirects(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap, _, otherID := twoLocalKegs(t, fx) - - out, err := tap.Stats(fx.Context(), tapper.StatsOptions{ - NodeID: "keg:other/" + otherID.PathNumeric(), - KegTargetOptions: tapper.KegTargetOptions{Keg: "current"}, - }) - require.NoError(t, err) - require.NotEmpty(t, out) -} diff --git a/pkg/tapper/node_ref_resolve.go b/pkg/tapper/node_ref_resolve.go index b2a00d68..595c587c 100644 --- a/pkg/tapper/node_ref_resolve.go +++ b/pkg/tapper/node_ref_resolve.go @@ -21,10 +21,9 @@ type RefContext struct { // - RefLocal: the current keg, with the bare node id. // - RefAlias: the alias is resolved against the current keg's Links table // first (so authored links travel with the keg), then the -// tap-config kegs map. +// Tapper configuration. // - RefQualified: a (hub, namespace, keg) reference whose hub is implied from -// the current keg's hub; the reserved @local namespace pins -// the local hub regardless of the current keg's hub. +// the current keg's hub. func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContext) (keg.Keg, keg.NodeId, error) { if ref == nil { return nil, keg.NodeId{}, fmt.Errorf("nil node ref") @@ -51,10 +50,9 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex return k, node, nil case keg.RefQualified: - // @local pins the local hub; any other namespace implies the current - // keg's hub from context. + // A qualified namespace implies the current keg's hub from context. hub := "" - if ref.Namespace != LocalHubName && rc.CurrentKeg != nil && rc.CurrentKeg.Target() != nil { + if rc.CurrentKeg != nil && rc.CurrentKeg.Target() != nil { hub = strings.TrimSpace(rc.CurrentKeg.Target().Hub) } cfg, err := t.ConfigService.Config() @@ -84,7 +82,7 @@ func (t *Tap) ResolveNodeRef(ctx context.Context, ref *keg.NodeRef, rc RefContex // - "keg:/" redirect to the keg the alias names (the // current keg's Links table first, then tap-config kegs). // - "keg:@//" redirect to the fully qualified keg; the hub -// is implied from currentKeg's hub, @local pins the local hub. +// is implied from currentKeg's hub. // // currentKeg is the keg already resolved by the caller (via resolveKeg); it // supplies the RefLocal target and the context a relative ref resolves against. @@ -103,7 +101,7 @@ func (t *Tap) resolveNodeArg(ctx context.Context, currentKeg keg.Keg, raw string // map. func (t *Tap) resolveRefAlias(ctx context.Context, alias string, rc RefContext) (*keg.Target, error) { if rc.CurrentKeg != nil { - if kc, err := rc.CurrentKeg.Config(ctx); err == nil && kc != nil { + if kc, err := rc.CurrentKeg.Settings(ctx); err == nil && kc != nil { if target, err := kc.ResolveAlias(alias); err == nil { return target, nil } diff --git a/pkg/tapper/tap.go b/pkg/tapper/tap.go index c00ed9b5..f57b3783 100644 --- a/pkg/tapper/tap.go +++ b/pkg/tapper/tap.go @@ -40,13 +40,6 @@ type Tap struct { // single resolver covers the whole surface. Left nil for the CLI, which keeps // the standard config-driven resolution. KegResolver func(ctx context.Context, opts KegTargetOptions, role FlightRole) (keg.Keg, error) - - // OrientationDetailsResolver is the hosted-MCP batch seam. Tapper Hub - // injects a catalog-backed implementation so minimal keg_settings requests - // can authorize and load several selected KEGs without loopback HTTP or - // opening each Keg independently. Local and ordinary remote clients leave - // it nil and use the standard config/hub resolution path. - OrientationDetailsResolver func(ctx context.Context, refs []string) ([]HubOrientationDetail, error) } type TapOptions struct { @@ -118,18 +111,6 @@ type KegTargetOptions struct { // Empty means resolve the hub from the namespace as usual. Hub string - // Project resolves using project-local keg discovery. Not exposed as a tap - // flag; retained for the pruned `keg` binary (ForceProjectResolution) and - // the keg-create destination flags. - Project bool - - // Cwd resolves project keg at the current working directory instead of git root. - // Works standalone or combined with Project. - Cwd bool - - // Path is an explicit local project path used for project keg discovery. - Path string - // Flight is optional task context that can restrict which kegs are available // and injects agent instructions. It composes with the single-keg selectors // (Keg/Namespace/Hub): the selector picks a keg and the flight gates it unless @@ -149,8 +130,7 @@ type KegTargetOptions struct { // RequireBootstrap makes config-driven resolution fail with // ErrNotBootstrapped when no user config exists. Set by the full `tap` - // surface and the MCP server; left false by the pruned `keg` binary and by - // direct Tap API callers (e.g. tests). + // surface and the MCP server; direct Tap API callers may leave it false. RequireBootstrap bool } @@ -199,9 +179,6 @@ func (t *Tap) resolveKegForRoles(ctx context.Context, opts KegTargetOptions, ide Keg: opts.Keg, Namespace: opts.Namespace, Hub: opts.Hub, - Project: opts.Project, - Cwd: opts.Cwd, - Path: opts.Path, RequireBootstrap: opts.RequireBootstrap, NoCache: false, }) diff --git a/pkg/tapper/tap_actor_test.go b/pkg/tapper/tap_actor_test.go deleted file mode 100644 index e66bf959..00000000 --- a/pkg/tapper/tap_actor_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package tapper_test - -import ( - "testing" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -func TestTapCreateDefaultsToHumanSchemaPolicy(t *testing.T) { - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - ctx := fx.Context() - local, err := keg.NewKegFromTarget(ctx, keg.NewFile("/home/testuser/kegs/@local/test"), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, local.CreateSchema(ctx, "task", []byte(`type: task -meta: - type: object - required: [type] - properties: - type: {const: task} -`))) - require.NoError(t, keg.UpdateConfig(ctx, local, func(cfg *keg.Config) { - cfg.SchemaPolicy = &keg.SchemaPolicy{ - Strict: false, - Human: keg.ValidationModeOff, - Agent: keg.ValidationModeBlock, - API: keg.ValidationModeBlock, - } - })) - - _, err = tap.Create(ctx, tapper.CreateOptions{Title: "Human policy write"}) - require.NoError(t, err, "CLI write should use human:off rather than agent:block") -} diff --git a/pkg/tapper/tap_batch.go b/pkg/tapper/tap_batch.go index 86922cc9..7c8e2f79 100644 --- a/pkg/tapper/tap_batch.go +++ b/pkg/tapper/tap_batch.go @@ -88,7 +88,10 @@ type BatchMetaOptions struct { Updates []BatchMetaUpdate } type BatchMetaResult struct { - NodeID string `json:"node_id"` + NodeID string `json:"node_id"` + // Hash is the node's precondition token, echoed back as ExpectedHash on + // the next write to this node. It covers content and metadata together. + Hash string `json:"hash"` Content string `json:"content"` } @@ -150,7 +153,7 @@ func (t *Tap) MetaBatch(ctx context.Context, opts BatchMetaOptions) ([]BatchMeta } out := make([]BatchMetaResult, len(views)) for i, view := range views { - out[i] = BatchMetaResult{NodeID: view.ID.Path(), Content: string(view.Meta)} + out[i] = BatchMetaResult{NodeID: view.ID.Path(), Hash: view.Hash(), Content: string(view.Meta)} } return out, nil, nil } diff --git a/pkg/tapper/tap_bootstrap.go b/pkg/tapper/tap_bootstrap.go index de1a841d..d0003777 100644 --- a/pkg/tapper/tap_bootstrap.go +++ b/pkg/tapper/tap_bootstrap.go @@ -5,38 +5,14 @@ import ( "errors" "fmt" "net/url" - "os" "strings" - "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" ) -// localHubKey returns the map key for this machine's built-in local hub: the -// sanitized machine hostname, falling back to LocalHubName when the hostname is -// unavailable. Keying by hostname keeps a config that travels between machines -// unambiguous, while the reserved @local namespace stays the portable handle -// for references. The hostname is read from the runtime environment first (so -// it is deterministic under a sandboxed runtime and overridable in CI) and from -// the OS otherwise. -func localHubKey(rt *toolkit.Runtime) string { - host := strings.TrimSpace(rt.Env().Get("HOSTNAME")) - if host == "" { - if h, err := os.Hostname(); err == nil { - host = h - } - } - if name := sanitizeHubName(host); name != "" { - return name - } - return LocalHubName -} - // Bootstrap deployment kinds. Each maps onto an existing hub kind/shape — there // is no new config field, only a guided way to pick one. const ( - // BootstrapKindLocal sets up only the built-in local filesystem hub. - BootstrapKindLocal = "local" // BootstrapKindCloud targets the compiled-in atlas remote hub. BootstrapKindCloud = "cloud" // BootstrapKindEnterprise registers a user-supplied remote HTTP endpoint. @@ -47,7 +23,7 @@ const ( // materializes or refreshes the user-level tapper config around a deployment // kind. type BootstrapOptions struct { - // Kind selects the deployment: local | cloud | enterprise. Empty defaults + // Kind selects the deployment: cloud | enterprise. Empty defaults // to cloud (atlas is the compiled-in default hub). Kind string // Endpoint is the hub base URL; required when Kind == enterprise, ignored @@ -56,8 +32,7 @@ type BootstrapOptions struct { // HubName overrides the hub key written for an enterprise endpoint. Empty // derives it from the endpoint host (see deriveHubName). HubName string - // Namespace overrides the fallback namespace. Empty auto-derives it from - // the OS user, then LocalHubName. + // Namespace overrides the hub's default namespace. Namespace string } @@ -68,7 +43,7 @@ type BootstrapResult struct { Created bool // true when a fresh file was created, false on update Kind string // normalized deployment kind Hub string // hub name written as fallbackHub - HubURL string // login/display URL for cloud/enterprise; "" for local + HubURL string // login/display URL Namespace string // resolved fallback namespace Warnings []ConfigWarning // semantic warnings from ValidateConfig } @@ -76,46 +51,34 @@ type BootstrapResult struct { // Bootstrap creates or refreshes the user-level config for a chosen deployment // kind so plain `tap` commands resolve without per-invocation flags. It writes // the FALLBACK hub (the user/global convention — project config owns the -// high-precedence default* slots) and always ensures the built-in local hub is -// present and that the reserved @local namespace maps to it. +// high-precedence default* slots). // // It does not write a global fallbackNamespace or a per-user namespace→hub // entry: the preferred namespace comes from the resolved hub's own namespace -// field. For local that is @local; for cloud/enterprise it is the logged-in -// user's home namespace, adopted onto the hub after login by -// SetBootstrapNamespace. The only namespace→hub entry written is local→localHub. +// field. It is the logged-in user's home namespace, adopted onto the hub after +// login by SetBootstrapNamespace. // -// It is idempotent: an existing config is loaded and only the fallback hub, the -// local namespace mapping, and the kind's hub entry are touched, so user-defined -// kegs/kegMap survive a re-run untouched. +// It is idempotent: an existing config is loaded and only the fallback hub and +// the selected hub entry are touched, so extension fields and kegMap rules +// survive a re-run untouched. func (t *Tap) Bootstrap(ctx context.Context, opts BootstrapOptions) (*BootstrapResult, error) { kind := strings.TrimSpace(strings.ToLower(opts.Kind)) if kind == "" { kind = BootstrapKindCloud } switch kind { - case BootstrapKindLocal, BootstrapKindCloud, BootstrapKindEnterprise: + case BootstrapKindCloud, BootstrapKindEnterprise: default: - return nil, fmt.Errorf("unknown bootstrap kind %q (expected local, cloud, or enterprise)", opts.Kind) + return nil, fmt.Errorf("unknown bootstrap kind %q (expected cloud or enterprise)", opts.Kind) } - // Namespace stored on the kind's hub entry. For a local deployment the home - // namespace is the reserved @local. For cloud/enterprise the authoritative + // Namespace stored on the hub entry. The authoritative // value is the logged-in user's home namespace, which only the hub knows; it // is adopted after login via SetBootstrapNamespace and lives on the hub's own // namespace field. Until then it stays empty rather than guessing the OS user // — a bogus guess would resolve bare references to the wrong namespace // instead of erroring clearly. An explicit opts.Namespace always wins. namespace := strings.TrimSpace(opts.Namespace) - if namespace == "" && kind == BootstrapKindLocal { - namespace = LocalHubName - } - - localRoot, err := defaultUserKegRoot(t.Runtime) - if err != nil { - return nil, fmt.Errorf("unable to resolve local keg root: %w", err) - } - path := t.PathService.UserConfig() // Load the existing user config so a re-run is idempotent; only a genuine @@ -130,33 +93,13 @@ func (t *Tap) Bootstrap(ctx context.Context, opts BootstrapOptions) (*BootstrapR cfg = existing case errors.Is(err, keg.ErrNotExist): // Start minimal rather than from DefaultUserConfig: the per-kind branch - // below adds the remote hub it needs, so a `local` bootstrap stays - // local-only instead of carrying an unsolicited atlas entry. The - // always-ensure step seeds the built-in local hub. + // below adds exactly the selected remote hub. cfg = &Config{data: &configDTO{KegMap: []KegMapEntry{}}} created = true default: return nil, fmt.Errorf("unable to load user config: %w", err) } - // The built-in local hub is always available so local kegs work regardless - // of the chosen deployment. It is keyed by the machine hostname and defaults - // to the reserved @local namespace; on-disk kegs live at - // /@local/. - localKey := localHubKey(t.Runtime) - if _, ok := cfg.Hubs()[localKey]; !ok { - if err := cfg.SetHub(localKey, HubEntry{Kind: HubKindLocal, DefaultNamespace: LocalHubName, BasePath: localRoot}); err != nil { - return nil, err - } - } - // Pin the reserved @local namespace to this machine's local hub. This is the - // only namespace→hub entry bootstrap writes: every other namespace's hub is - // resolved from the default/fallback hub chain, and the preferred namespace - // comes from that hub's own namespace field — so no per-user entry is needed. - if err := cfg.SetNamespace(LocalHubName, NamespaceRef{Hub: localKey}); err != nil { - return nil, err - } - // Resolve the kind-specific hub: its config entry, the fallbackHub name, // and the URL the CLI uses for an optional login. var ( @@ -164,9 +107,6 @@ func (t *Tap) Bootstrap(ctx context.Context, opts BootstrapOptions) (*BootstrapR hubURL string ) switch kind { - case BootstrapKindLocal: - hubName = localKey - case BootstrapKindCloud: hubName = DefaultHubName hubURL = DefaultHubURL @@ -233,7 +173,6 @@ func (t *Tap) Bootstrap(ctx context.Context, opts BootstrapOptions) (*BootstrapR // // It is idempotent and a no-op when namespace is blank, or when hubName is // unknown/blank (nothing to adopt onto), keeping the call safe in either case. -// The always-present local hub keeps its reserved @local namespace. func (t *Tap) SetBootstrapNamespace(ctx context.Context, hubName, namespace string) error { namespace = strings.TrimSpace(namespace) if namespace == "" { diff --git a/pkg/tapper/tap_bootstrap_test.go b/pkg/tapper/tap_bootstrap_test.go index e8127ac5..fad6043e 100644 --- a/pkg/tapper/tap_bootstrap_test.go +++ b/pkg/tapper/tap_bootstrap_test.go @@ -9,13 +9,8 @@ import ( "github.com/stretchr/testify/require" ) -// testHost is the deterministic hostname pinned in bootstrap tests so the -// machine-keyed local hub is stable across machines and CI. -const testHost = "testhost" - func newBootstrapTap(t *testing.T, fx *sandbox.Sandbox) *tapper.Tap { t.Helper() - require.NoError(t, fx.Runtime().Set("HOSTNAME", testHost)) tap, err := tapper.NewTap(tapper.TapOptions{ Root: "/home/testuser", Runtime: fx.Runtime(), @@ -24,39 +19,6 @@ func newBootstrapTap(t *testing.T, fx *sandbox.Sandbox) *tapper.Tap { return tap } -// TestBootstrap_Local sets up only the built-in local filesystem hub: no remote -// URL, fallbackHub points at local. -func TestBootstrap_Local(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap := newBootstrapTap(t, fx) - - res, err := tap.Bootstrap(fx.Context(), tapper.BootstrapOptions{Kind: tapper.BootstrapKindLocal}) - require.NoError(t, err) - require.True(t, res.Created) - require.Equal(t, tapper.BootstrapKindLocal, res.Kind) - require.Equal(t, testHost, res.Hub) - require.Empty(t, res.HubURL, "local has no remote URL to log in against") - require.Equal(t, tapper.LocalHubName, res.Namespace, "a local deployment defaults to the @local namespace") - - cfg, err := tap.ConfigService.UserConfig() - require.NoError(t, err) - require.Equal(t, testHost, cfg.FallbackHub()) - require.Empty(t, cfg.FallbackNamespace(), "namespace comes from the hub, not a global fallback") - hubs := cfg.Hubs() - require.Contains(t, hubs, testHost) - require.Equal(t, tapper.HubKindLocal, hubs[testHost].Kind) - require.Equal(t, tapper.LocalHubName, hubs[testHost].DefaultNamespace, "local hub defaults to @local") - require.NotEmpty(t, hubs[testHost].BasePath) - require.NotContains(t, hubs, tapper.DefaultHubName, "a fresh local bootstrap should not seed an atlas hub") - - // The only namespace→hub entry generated is local→localHub. - ns := cfg.Namespaces() - require.Len(t, ns, 1) - require.Equal(t, testHost, ns[tapper.LocalHubName].Hub) -} - // TestBootstrap_Cloud targets atlas and is also the default when Kind is empty. func TestBootstrap_Cloud(t *testing.T) { t.Parallel() @@ -76,15 +38,11 @@ func TestBootstrap_Cloud(t *testing.T) { require.Empty(t, cfg.FallbackNamespace(), "namespace comes from the hub, not a global fallback") hubs := cfg.Hubs() require.Contains(t, hubs, tapper.DefaultHubName) - require.Contains(t, hubs, testHost, "local hub is always ensured") require.Equal(t, tapper.HubKindRemote, hubs[tapper.DefaultHubName].Kind) require.Equal(t, tapper.DefaultHubURL, hubs[tapper.DefaultHubName].URL) require.Empty(t, hubs[tapper.DefaultHubName].DefaultNamespace, "cloud hub namespace stays empty until login adopts it") - // No per-user namespace entry: only local→localHub is generated. - ns := cfg.Namespaces() - require.Len(t, ns, 1) - require.Equal(t, testHost, ns[tapper.LocalHubName].Hub) + require.Empty(t, cfg.Namespaces()) } // TestBootstrap_Enterprise registers a custom remote endpoint and derives the @@ -113,12 +71,7 @@ func TestBootstrap_Enterprise(t *testing.T) { require.Equal(t, tapper.HubKindRemote, hubs["acme"].Kind) require.Equal(t, "https://keg.acme.com", hubs["acme"].URL) require.Empty(t, hubs["acme"].DefaultNamespace, "enterprise hub namespace stays empty until login adopts it") - require.Contains(t, hubs, testHost) - - // No per-user namespace entry: only local→localHub is generated. - ns := cfg.Namespaces() - require.Len(t, ns, 1) - require.Equal(t, testHost, ns[tapper.LocalHubName].Hub) + require.Empty(t, cfg.Namespaces()) } // TestBootstrap_Enterprise_SchemeAddedAndHubNameOverride covers a bare host @@ -165,35 +118,6 @@ func TestBootstrap_UnknownKind(t *testing.T) { require.Contains(t, err.Error(), "unknown bootstrap kind") } -func TestSetBootstrapFlight_ValidatesCanonicalizesAndResetsConfig(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - tap := newBootstrapTap(t, fx) - _, err := tap.Bootstrap(fx.Context(), tapper.BootstrapOptions{Kind: tapper.BootstrapKindLocal}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile( - "/home/testuser/.local/share/tapper/kegs/flights.d/focused.yaml", - []byte("title: Focused\n"), 0o644)) - - // Prime the merged cache before the write; SetBootstrapFlight must reset it. - cfg, err := tap.ConfigService.Config() - require.NoError(t, err) - require.Empty(t, cfg.Flight()) - require.NoError(t, tap.SetBootstrapFlight(fx.Context(), "+focused")) - - userCfg, err := tap.ConfigService.UserConfig() - require.NoError(t, err) - require.Equal(t, "@local/+focused", userCfg.Flight()) - merged, err := tap.ConfigService.Config() - require.NoError(t, err) - require.Equal(t, "@local/+focused", merged.Flight()) - - err = tap.SetBootstrapFlight(fx.Context(), "+missing") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid bootstrap flight") -} - // TestBootstrap_Enterprise_NameCollisionSuffixes confirms a derived name that // already maps to a different URL gets a numeric suffix rather than clobbering. func TestBootstrap_Enterprise_NameCollisionSuffixes(t *testing.T) { @@ -233,11 +157,18 @@ func TestBootstrap_Idempotent_PreservesUserConfig(t *testing.T) { existing := strings.TrimSpace(` fallbackHub: stale fallbackNamespace: olduser +vendorFeature: + enabled: true kegMap: - alias: "@alice/notes" pathPrefix: ~/repos/notes + vendorMapping: keep hubs: - atlas: { kind: remote, url: https://atlas.foldwise.ai, tokenEnv: ATLAS_API_KEY } + atlas: + kind: remote + url: https://atlas.foldwise.ai + tokenEnv: ATLAS_API_KEY + vendorHub: keep `) + "\n" require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(existing), 0o644)) @@ -252,11 +183,13 @@ hubs: // Bootstrap no longer manages fallbackNamespace, so a pre-existing value is // left untouched rather than overwritten with the OS user. require.Equal(t, "olduser", cfg.FallbackNamespace()) - // The idempotent re-run still seeds the local namespace mapping. - require.Equal(t, testHost, cfg.Namespaces()[tapper.LocalHubName].Hub) + require.Empty(t, cfg.Namespaces()) // The user-defined keg-map entry survives the idempotent re-run. out, err := cfg.ToYAML() require.NoError(t, err) require.Contains(t, string(out), "@alice/notes") + require.Contains(t, string(out), "vendorMapping: keep") + require.Contains(t, string(out), "vendorHub: keep") + require.Contains(t, string(out), "vendorFeature:") } diff --git a/pkg/tapper/tap_cat.go b/pkg/tapper/tap_cat.go index 1a749386..7cb7451a 100644 --- a/pkg/tapper/tap_cat.go +++ b/pkg/tapper/tap_cat.go @@ -101,57 +101,23 @@ func (t *Tap) Cat(ctx context.Context, opts CatOptions) (string, error) { }) } - base, err := t.resolveKeg(ctx, opts.KegTargetOptions) + views, err := t.CatViews(ctx, opts) if err != nil { - return "", fmt.Errorf("unable to open keg: %w", err) + return "", err } + return FormatCatViews(ctx, views, opts), nil +} - var views []keg.NodeView - if opts.Query != "" { - views, err = base.ReadNodes(ctx, keg.ReadNodesOptions{Query: opts.Query, Touch: true}) - if err != nil { - return "", fmt.Errorf("unable to query nodes: %w", err) - } - } else { - type group struct { - k keg.Keg - ids []keg.NodeId - positions []int - } - groups := map[string]*group{} - order := []string{} - views = make([]keg.NodeView, len(nodeIDs)) - for pos, raw := range nodeIDs { - resolved, id, resolveErr := t.resolveNodeArg(ctx, base, raw) - if resolveErr != nil { - return "", resolveErr - } - key := describeKeg(resolved) - g := groups[key] - if g == nil { - g = &group{k: resolved} - groups[key] = g - order = append(order, key) - } - g.ids = append(g.ids, id) - g.positions = append(g.positions, pos) - } - for _, key := range order { - g := groups[key] - batch, batchErr := g.k.ReadNodes(ctx, keg.ReadNodesOptions{NodeIDs: g.ids, Touch: true}) - if batchErr != nil { - return "", fmt.Errorf("unable to read nodes in %s: %w", key, batchErr) - } - for i, view := range batch { - views[g.positions[i]] = view - } - } - } +// FormatCatViews renders views the way Cat does. It is exported so a caller +// that already holds the views — one that needed CatViews for the per-node +// hashes — can produce the same text without reading the nodes a second time. +// Re-reading would also double every access touch. +func FormatCatViews(ctx context.Context, views []keg.NodeView, opts CatOptions) string { if len(views) == 0 { - return "", nil + return "" } if len(views) == 1 { - return strings.TrimRight(formatCatView(ctx, views[0], opts, false), "\n") + "\n", nil + return strings.TrimRight(formatCatView(ctx, views[0], opts, false), "\n") + "\n" } // Multiple nodes: emit a YAML document stream where every document is @@ -172,7 +138,72 @@ func (t *Tap) Cat(ctx context.Context, opts CatOptions) (string, error) { buf.WriteString(strings.TrimRight(out, "\n")) buf.WriteString("\n") } - return buf.String(), nil + return buf.String() +} + +// CatViews returns the node views Cat renders, in caller order. It exists so +// callers needing structured per-node state — an agent reading the content +// hash it must echo back on its next write — do not have to parse Cat's +// formatted output. The editor and TTY delegation in Cat is deliberately +// absent: those paths are interactive and produce no views. +func (t *Tap) CatViews(ctx context.Context, opts CatOptions) ([]keg.NodeView, error) { + nodeIDs := opts.NodeIDs + if opts.Query != "" && len(nodeIDs) > 0 { + return nil, fmt.Errorf("cannot specify both node IDs and --query") + } + if len(nodeIDs) == 0 && opts.Query == "" { + return nil, nil + } + + base, err := t.resolveKeg(ctx, opts.KegTargetOptions) + if err != nil { + return nil, fmt.Errorf("unable to open keg: %w", err) + } + + if opts.Query != "" { + views, queryErr := base.ReadNodes(ctx, keg.ReadNodesOptions{Query: opts.Query, Touch: true}) + if queryErr != nil { + return nil, fmt.Errorf("unable to query nodes: %w", queryErr) + } + return views, nil + } + + // Group by resolved keg so a cross-keg id list still costs one read per + // keg, then scatter each batch back to its caller-order position. + type group struct { + k keg.Keg + ids []keg.NodeId + positions []int + } + groups := map[string]*group{} + order := []string{} + views := make([]keg.NodeView, len(nodeIDs)) + for pos, raw := range nodeIDs { + resolved, id, resolveErr := t.resolveNodeArg(ctx, base, raw) + if resolveErr != nil { + return nil, resolveErr + } + key := describeKeg(resolved) + g := groups[key] + if g == nil { + g = &group{k: resolved} + groups[key] = g + order = append(order, key) + } + g.ids = append(g.ids, id) + g.positions = append(g.positions, pos) + } + for _, key := range order { + g := groups[key] + batch, batchErr := g.k.ReadNodes(ctx, keg.ReadNodesOptions{NodeIDs: g.ids, Touch: true}) + if batchErr != nil { + return nil, fmt.Errorf("unable to read nodes in %s: %w", key, batchErr) + } + for i, view := range batch { + views[g.positions[i]] = view + } + } + return views, nil } func formatCatView(ctx context.Context, view keg.NodeView, opts CatOptions, withID bool) string { diff --git a/pkg/tapper/tap_cat_format_test.go b/pkg/tapper/tap_cat_format_test.go index 902ee3c5..4f3d8e4e 100644 --- a/pkg/tapper/tap_cat_format_test.go +++ b/pkg/tapper/tap_cat_format_test.go @@ -32,15 +32,10 @@ func TestDescribeKeg(t *testing.T) { want: "keg:@jlrickert/example (hub https://tapper-1-jlrickert.dev.foldwise.ai)", }, { - name: "local keg shows ref without hub", + name: "ordinary local namespace shows ref without hub", k: kegWithTarget(&keg.Target{Namespace: "local", KegName: "example"}), want: "keg:@local/example", }, - { - name: "file keg shows path", - k: kegWithTarget(&keg.Target{File: "/home/me/kegs/notes"}), - want: "/home/me/kegs/notes", - }, } for _, tt := range tests { diff --git a/pkg/tapper/tap_concurrent_test.go b/pkg/tapper/tap_concurrent_test.go deleted file mode 100644 index 57c20cd4..00000000 --- a/pkg/tapper/tap_concurrent_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package tapper_test - -import ( - "bytes" - "fmt" - "io" - "sync" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// setupTapWithKeg creates a Tap instance with a keg initialized at -// ~/kegs/test inside the sandbox. -func setupTapWithKeg(t *testing.T, fx *sandbox.Sandbox) *tapper.Tap { - t.Helper() - - root := "/home/testuser/work" - require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: root, - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - // Write user config with a local hub and fallback; the bare name "test" - // resolves to @local/test under the hub's basePath. - userCfg := `fallbackKeg: test -fallbackNamespace: local -hubs: - home: - kind: local - basePath: /home/testuser/kegs -` - require.NoError(t, fx.Runtime().Mkdir(tap.PathService.ConfigRoot, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - - // Create keg directory. Discovery needs a keg file, but Init writes one. - // We use Resolve with explicit URL to skip discovery, then Init creates - // a proper config file. - kegDir := "/home/testuser/kegs/@local/test" - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - - k, err := keg.NewKegFromTarget(fx.Context(), keg.NewFile(kegDir), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, k.Init(fx.Context())) - makeKegNonStrict(t, fx.Context(), k) - - return tap -} - -// TestTapCreate_Concurrent verifies concurrent Tap.Create calls with piped -// stdin all produce unique nodes. -func TestTapCreate_Concurrent(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - tap := setupTapWithKeg(t, fx) - - const N = 10 - ids := make([]keg.NodeId, N) - errs := make([]error, N) - - var wg sync.WaitGroup - for i := range N { - wg.Add(1) - go func(idx int) { - defer wg.Done() - content := fmt.Sprintf("# Piped Node %d\n\nCreated via piped stdin.\n", idx) - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - ids[idx] = id - errs[idx] = err - }(i) - } - wg.Wait() - - for i, err := range errs { - require.NoError(t, err, "goroutine %d failed Create", i) - } - - seen := make(map[int]bool) - for i, id := range ids { - require.False(t, seen[id.ID], "duplicate ID %d from goroutine %d", id.ID, i) - seen[id.ID] = true - } -} - -// TestTapEdit_ConcurrentDifferentNodes verifies concurrent Tap edit operations -// on different nodes via piped stdin. -func TestTapEdit_ConcurrentDifferentNodes(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - tap := setupTapWithKeg(t, fx) - - // Pre-create nodes. - const N = 5 - nodeIDs := make([]string, N) - for i := range N { - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf("# Node %d\n\nInitial.\n", i)))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - require.NoError(t, err) - nodeIDs[i] = id.String() - } - - // Concurrent edits. - var wg sync.WaitGroup - errs := make([]error, N) - for i := range N { - wg.Add(1) - go func(idx int) { - defer wg.Done() - content := fmt.Sprintf("# Edited Node %d\n\nEdited content.\n", idx) - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - err := tap.Edit(fx.Context(), tapper.EditOptions{ - NodeID: nodeIDs[idx], - Stream: stream, - }) - errs[idx] = err - }(i) - } - wg.Wait() - - for i, err := range errs { - require.NoError(t, err, "goroutine %d failed Edit", i) - } -} diff --git a/pkg/tapper/tap_config.go b/pkg/tapper/tap_config.go index d1416330..d6ca1e80 100644 --- a/pkg/tapper/tap_config.go +++ b/pkg/tapper/tap_config.go @@ -7,11 +7,10 @@ import ( "fmt" "io" "os" - "path/filepath" - "runtime" "strings" "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/pkg/schemas" ) type ConfigOptions struct { @@ -65,6 +64,7 @@ func (t *Tap) Config(opts ConfigOptions) (string, error) { if err != nil { return "", fmt.Errorf("unable to serialize config: %w", err) } + data = schemas.ReplaceModeline(data, schemas.Modeline(t.Runtime, schemas.TapConfig)) return string(data), nil } @@ -102,10 +102,13 @@ func (t *Tap) ConfigTemplate(opts ConfigTemplateOptions) (string, error) { if opts.Project { cfg = DefaultProjectConfig("project", "kegs") } else { - cfg = DefaultUserConfig("pub", defaultTemplateKegRoot(t.Runtime)) + cfg = DefaultUserConfig("pub") } data, err := cfg.ToYAML() - return string(data), err + if err != nil { + return "", err + } + return string(schemas.ReplaceModeline(data, schemas.Modeline(t.Runtime, schemas.TapConfig))), nil } // ConfigEdit edits the selected tap config file. @@ -141,7 +144,7 @@ func (t *Tap) ConfigEdit(ctx context.Context, opts ConfigEditOptions) error { if opts.User { // User config is the base layer — seed it with real onboarding // defaults (hubs, local namespace, etc.). - cfg := DefaultUserConfig("public", defaultTemplateKegRoot(t.Runtime)) + cfg := DefaultUserConfig("public") if err := cfg.Write(t.Runtime, resolvedPath); err != nil { return fmt.Errorf("unable to create default config: %w", err) } @@ -149,7 +152,7 @@ func (t *Tap) ConfigEdit(ctx context.Context, opts ConfigEditOptions) error { // Project config: seed a fully commented template so an abandoned // edit leaves an inert file rather than authoritative default* slots // that would silently override user-level resolution. - tmpl, tmplErr := projectConfigTemplate() + tmpl, tmplErr := projectConfigTemplate(t.Runtime) if tmplErr != nil { return tmplErr } @@ -224,7 +227,6 @@ var ConfigExplainFields = []string{ "defaultNamespace", "fallbackNamespace", "disableAtlasHub", - "disableLocalHub", "disableTelemetry", } @@ -262,11 +264,6 @@ func configFieldGetter(cfg *Config, field string) string { return "true" } return "" - case "disableLocalHub": - if cfg.DisableLocalHub() { - return "true" - } - return "" case "disableTelemetry": if cfg.DisableTelemetry() { return "true" @@ -314,14 +311,9 @@ func (t *Tap) ConfigExplain(ctx context.Context, opts ConfigExplainOptions) ([]C mergedVal := configFieldGetter(merged, field) // Walk from most-specific to least-specific to find which source set this value. - // The agent sits between the env and file layers for flight only, mirroring - // the resolution order in ConfigService.load — otherwise this would report a - // project config that the agent's flight actually overrode. source := "default" if envVal := configFieldGetter(envCfg, field); envVal != "" { source = "env vars" - } else if agentName := agentFlightSource(merged, field); agentName != "" { - source = fmt.Sprintf("agent %q", agentName) } else if projVal := configFieldGetter(projectCfg, field); projVal != "" { source = "project config" } else if userVal := configFieldGetter(userCfg, field); userVal != "" { @@ -339,30 +331,6 @@ func (t *Tap) ConfigExplain(ctx context.Context, opts ConfigExplainOptions) ([]C } // loadEnvConfig builds a Config from TAP_* env vars, or returns nil if none are set. -// agentFlightSource names the agent that supplied field's value, or "" when the -// agent did not. Only "flight" can come from an agent; the agent's own entry is -// a plain config value like any other. -func agentFlightSource(merged *Config, field string) string { - if field != "flight" || merged == nil { - return "" - } - name := merged.AgentName() - if name == "" { - return "" - } - entry, ok := merged.Agent(name) - if !ok { - return "" - } - // Compare against the merged value rather than assuming the overlay ran: a - // TAP_FLIGHT override is caught by the env branch before this one, but an - // agent whose flight is empty never contributed and must not be credited. - if flight := strings.TrimSpace(entry.Flight); flight != "" && flight == merged.Flight() { - return name - } - return "" -} - func (t *Tap) loadEnvConfig() *Config { getenv := t.Runtime.Env().Get envMap := make(map[string]string) @@ -374,20 +342,3 @@ func (t *Tap) loadEnvConfig() *Config { } return configFromEnvMap(envMap) } - -// defaultTemplateKegRoot returns the user-visible default basePath written into -// a starter config's local hub (~/Documents/kegs). It is distinct from -// defaultUserKegRoot, the platform data-dir fallback used at resolve time. -func defaultTemplateKegRoot(rt *toolkit.Runtime) string { - switch runtime.GOOS { - case "darwin", "linux": - return "~/Documents/kegs" - default: - if rt != nil { - if home, err := rt.GetHome(); err == nil && strings.TrimSpace(home) != "" { - return filepath.Join(home, "Documents", "kegs") - } - } - return "~/Documents/kegs" - } -} diff --git a/pkg/tapper/tap_config_test.go b/pkg/tapper/tap_config_test.go deleted file mode 100644 index 83137fcb..00000000 --- a/pkg/tapper/tap_config_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package tapper - -import ( - "runtime" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDefaultTemplateKegRoot(t *testing.T) { - t.Parallel() - - got := defaultTemplateKegRoot(nil) - - switch runtime.GOOS { - case "darwin", "linux": - require.Equal(t, "~/Documents/kegs", got) - default: - require.NotEmpty(t, got) - require.True(t, strings.Contains(got, "Documents")) - require.True(t, strings.Contains(got, "kegs")) - } -} diff --git a/pkg/tapper/tap_create_test.go b/pkg/tapper/tap_create_test.go deleted file mode 100644 index bf54b0b8..00000000 --- a/pkg/tapper/tap_create_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package tapper_test - -import ( - "bytes" - "fmt" - "io" - "testing" - - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// TestCreate_InteractiveIDConsistency verifies that the node ID returned by -// an interactive create (TTY mode) matches the node that is actually -// persisted. This is the reproduction test for the double-allocation bug -// where Next() was called once for the editor scaffold and again inside -// Keg.Create(), causing the editor to show node N while content was saved -// to node N+1. -// -// Since we cannot open a real TTY editor in tests, we simulate the -// interactive flow by using the non-interactive API and then verifying that -// the returned ID points to a node whose content matches what was created. -// The actual fix is verified by testing the create-then-edit flow produces -// consistent IDs. -func TestCreate_InteractiveIDConsistency(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - // Create a node via piped stdin (simulates what the fixed interactive - // flow does internally: Create then edit). - content := "# Test Node\n\nSome content.\n" - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - require.NoError(t, err) - - // The returned ID must point to a real node with the expected content. - catOutput, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{id.String()}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, catOutput, "# Test Node") - require.Contains(t, catOutput, "Some content.") -} - -// TestCreate_DoubleNextBugReproduction directly demonstrates the -// double-allocation bug at the Keg level. Calling Next() followed by -// Create() produces different IDs -- the editor would have shown -// nextID but content would have been saved under createID. -func TestCreate_DoubleNextBugReproduction(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - kegDir := "/home/testuser/kegs/test" - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - - k, err := keg.NewKegFromTarget(fx.Context(), keg.NewFile(kegDir), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, k.Init(fx.Context())) - makeKegNonStrict(t, fx.Context(), k) - - // This simulates what the OLD interactive create flow did: - // 1. Call Next() to get the node ID for the editor scaffold - nextID, err := k.Next(fx.Context()) - require.NoError(t, err) - - // 2. Call Create() which internally calls Next() again - createID, err := k.Create(fx.Context(), &keg.CreateOptions{ - Body: []byte("# Bug Reproduction\n\nThis content was saved.\n"), - }) - require.NoError(t, err) - - // BUG: nextID != createID -- the editor showed nextID but content - // was saved under createID. After the fix, the interactive flow - // no longer calls Next() separately, so this mismatch cannot occur. - require.NotEqual(t, nextID.ID, createID.ID.ID, - "Next()+Create() should produce different IDs (demonstrating the bug)") - require.Equal(t, nextID.ID+1, createID.ID.ID, - "Create() should have allocated the ID after the one Next() reserved") - - // The node at nextID should exist (Next creates the directory) but - // have no meaningful content -- it's an orphan from the double allocation. - exists, err := k.(*keg.LocalKeg).Repo.HasNode(fx.Context(), nextID) - require.NoError(t, err) - require.True(t, exists, "Next() should have reserved a directory for nextID") - - // The actual content should be at createID, not nextID. - body, err := k.(*keg.LocalKeg).Repo.ReadContent(fx.Context(), createID.ID) - require.NoError(t, err) - require.Contains(t, string(body), "Bug Reproduction") -} - -// TestCreate_DexConsistency verifies that after a create operation, the dex -// accurately reflects the created node (correct ID and title). -func TestCreate_DexConsistency(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - content := "# Dex Check Node\n\nContent for dex test.\n" - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - require.NoError(t, err) - - // Verify the node appears in the list output (which reads from dex). - // Use a format that starts each line with the node ID followed by a tab - // to avoid substring false positives (e.g., "1" matching inside "10"). - lines, err := tap.List(fx.Context(), tapper.ListOptions{}) - require.NoError(t, err) - - prefix := id.String() + "\t" - found := false - for _, line := range lines { - if len(line) >= len(prefix) && line[:len(prefix)] == prefix { - found = true - require.Contains(t, line, "Dex Check Node", - "dex entry for created node should contain the title") - break - } - } - require.True(t, found, "created node %s should appear in list output", id.String()) -} - -// TestCreate_NonInteractivePipedStdin verifies that the piped stdin create -// path works correctly and is unaffected by changes to the interactive flow. -func TestCreate_NonInteractivePipedStdin(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - content := "# Piped Create\n\nCreated via piped stdin.\n" - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - require.NoError(t, err) - require.True(t, id.ID > 0, "created node ID should be positive") - - catOutput, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{id.String()}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, catOutput, "# Piped Create") - require.Contains(t, catOutput, "Created via piped stdin.") -} - -// TestCreate_NonInteractiveTitleLead verifies that the title/lead flag -// create path works correctly and is unaffected by changes to the -// interactive flow. -func TestCreate_NonInteractiveTitleLead(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Title: "Flag Title", - Lead: "A lead paragraph.", - }) - require.NoError(t, err) - require.True(t, id.ID > 0, "created node ID should be positive") - - catOutput, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{id.String()}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, catOutput, "# Flag Title") - require.Contains(t, catOutput, "A lead paragraph.") -} - -// TestCreate_MultiplePipedCreatesSequential verifies that sequential piped -// creates produce unique, ascending node IDs with correct content. -func TestCreate_MultiplePipedCreatesSequential(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - const N = 5 - ids := make([]keg.NodeId, N) - for i := range N { - content := fmt.Sprintf("# Sequential Node %d\n\nContent %d.\n", i, i) - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tap.Create(fx.Context(), tapper.CreateOptions{ - Stream: stream, - }) - require.NoError(t, err) - ids[i] = id - } - - // IDs should be unique and ascending. - for i := 1; i < N; i++ { - require.Greater(t, ids[i].ID, ids[i-1].ID, - "node IDs should be ascending: %d should be > %d", ids[i].ID, ids[i-1].ID) - } - - // Each node should have the correct content. - for i, id := range ids { - catOutput, err := tap.Cat(fx.Context(), tapper.CatOptions{ - NodeIDs: []string{id.String()}, - ContentOnly: true, - }) - require.NoError(t, err) - require.Contains(t, catOutput, fmt.Sprintf("Sequential Node %d", i)) - } -} diff --git a/pkg/tapper/tap_doctor_test.go b/pkg/tapper/tap_doctor_test.go deleted file mode 100644 index cb68bc27..00000000 --- a/pkg/tapper/tap_doctor_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package tapper_test - -import ( - "context" - "testing" - - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -func setupDoctorKeg(t *testing.T) (*tapper.Tap, *keg.LocalKeg, context.Context) { - t.Helper() - fx := NewSandbox(t) - ctx := fx.Context() - - root := "/home/testuser/work" - require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: root, Runtime: fx.Runtime()}) - require.NoError(t, err) - userCfg := `fallbackKeg: test -fallbackNamespace: local -hubs: - home: - kind: local - basePath: /home/testuser/kegs -` - require.NoError(t, fx.Runtime().Mkdir(tap.PathService.ConfigRoot, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(userCfg), 0o644)) - - kegDir := "/home/testuser/kegs/@local/test" - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - k, err := keg.NewKegFromTarget(ctx, keg.NewFile(kegDir), fx.Runtime()) - require.NoError(t, err) - require.NoError(t, k.Init(ctx)) - makeKegNonStrict(t, ctx, k) - local, ok := k.(*keg.LocalKeg) - require.True(t, ok) - return tap, local, ctx -} - -func TestDoctorRetainsContentLinkMetadataStatsAndSchemaChecks(t *testing.T) { - tap, k, ctx := setupDoctorKeg(t) - - id, err := k.Create(ctx, &keg.CreateOptions{ - Title: "Needs attention", - Body: []byte("# Needs attention\n\n[missing](../99)\n"), - }) - require.NoError(t, err) - require.NoError(t, k.Repo.WriteMeta(ctx, id.ID, []byte("tags: [\n"))) - require.NoError(t, k.WriteSchema(ctx, "task", []byte(`type: task -meta: - type: object - required: [type] - properties: - type: - const: task -`))) - - issues, err := tap.Doctor(ctx, tapper.DoctorOptions{}) - require.NoError(t, err) - - kinds := map[string]bool{} - for _, issue := range issues { - kinds[issue.Kind] = true - require.NotEqual(t, "entity-missing", issue.Kind) - require.NotEqual(t, "entity-attr", issue.Kind) - require.NotEqual(t, "tag-missing", issue.Kind) - } - require.True(t, kinds["broken-link"], "doctor should retain broken-link checks: %#v", issues) - require.True(t, kinds["meta"], "doctor should retain metadata parsing checks: %#v", issues) - require.True(t, kinds["schema"], "doctor should retain schema checks: %#v", issues) -} - -func TestDoctorReportsContentAndStatsProblems(t *testing.T) { - tap, k, ctx := setupDoctorKeg(t) - id, err := k.Create(ctx, &keg.CreateOptions{Title: "Temporary", Body: []byte("# Temporary\n")}) - require.NoError(t, err) - require.NoError(t, k.Repo.WriteContent(ctx, id.ID, nil)) - require.NoError(t, k.Repo.WriteStats(ctx, id.ID, &keg.NodeStats{})) - - issues, err := tap.Doctor(ctx, tapper.DoctorOptions{}) - require.NoError(t, err) - kinds := map[string]bool{} - for _, issue := range issues { - kinds[issue.Kind] = true - } - require.True(t, kinds["content"]) - require.True(t, kinds["timestamp"]) -} diff --git a/pkg/tapper/tap_edit.go b/pkg/tapper/tap_edit.go index 924fe79a..f609d6e2 100644 --- a/pkg/tapper/tap_edit.go +++ b/pkg/tapper/tap_edit.go @@ -102,7 +102,11 @@ func (t *Tap) Meta(ctx context.Context, opts MetaOptions) (string, error) { if parseErr != nil { return "", fmt.Errorf("metadata from stdin is invalid: %w", parseErr) } - results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: id, Schema: opts.Schema, Meta: []byte(metaNode.ToYAML()), HasMeta: true, LockToken: keg.LockToken(opts.LockToken)}}) + view, readErr := k.ReadNode(ctx, id) + if readErr != nil { + return "", fmt.Errorf("unable to read node before metadata write: %w", readErr) + } + results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: id, Schema: opts.Schema, Meta: []byte(metaNode.ToYAML()), HasMeta: true, LockToken: keg.LockToken(opts.LockToken), ExpectedHash: view.Hash()}}) if err != nil { return "", fmt.Errorf("unable to save node metadata: %w", err) } @@ -124,11 +128,10 @@ func (t *Tap) Meta(ctx context.Context, opts MetaOptions) (string, error) { return strings.TrimRight(metaNode.ToYAML(), "\n"), nil } -// Edit opens a node in an editor. When the repository is an FsRepo, the real -// README.md is opened directly for in-place editing. Otherwise a temporary -// file with frontmatter is used and changes are split back on save. +// Edit opens a node in an editor using a temporary file with frontmatter. +// Changes are sent back to the Hub as the editor saves. // -// The temp file format (non-FsRepo) is: +// The temp file format is: // // --- // @@ -162,10 +165,7 @@ func (t *Tap) Edit(ctx context.Context, opts EditOptions) error { } return fmt.Errorf("unable to open node: %w", err) } - expectedHash := "" - if view.Stats != nil { - expectedHash = view.Stats.Hash() - } + expectedHash := view.Hash() _, err = t.applyEditedNodeRawExpectedSchema(ctx, k, id, pipedRaw, keg.LockToken(opts.LockToken), expectedHash, opts.Schema) return err } @@ -175,10 +175,9 @@ func (t *Tap) Edit(ctx context.Context, opts EditOptions) error { } // editWithTempFile is the editing flow that composes frontmatter + body into -// a temporary file. When the repository is an FsRepo, a reverse sync watcher -// monitors the real node files (README.md, meta.yaml) and re-composes the -// temp file when external changes are detected, so the editor can reload -// with :e! to pick up changes from other tap instances. +// a temporary file. A reverse sync watcher subscribes to Hub events and +// re-composes the temp file when external changes are detected, so the editor +// can reload with :e! to pick up changes from other clients. func (t *Tap) editWithTempFile(ctx context.Context, k keg.Keg, id keg.NodeId) error { return t.editWithTempFileSchema(ctx, k, id, "") } @@ -378,10 +377,7 @@ func (t *Tap) applyEditedNodeRawWithLock(ctx context.Context, k keg.Keg, id keg. } return fmt.Errorf("unable to open node: %w", err) } - expectedHash := "" - if view.Stats != nil { - expectedHash = view.Stats.Hash() - } + expectedHash := view.Hash() _, err = t.applyEditedNodeRawExpected(ctx, k, id, editedRaw, lockToken, expectedHash) return err } @@ -552,6 +548,11 @@ func (t *Tap) editMetaSchema(ctx context.Context, k keg.Keg, id keg.NodeId, sche } func (t *Tap) editMetaSchemaLocked(ctx context.Context, k keg.Keg, id keg.NodeId, schema string, lockToken keg.LockToken, stream *toolkit.Stream) error { + view, err := k.ReadNode(ctx, id) + if err != nil { + return fmt.Errorf("unable to read node before metadata edit: %w", err) + } + expectedHash := view.Hash() raw, err := k.GetMetaRaw(ctx, id) if err != nil && !errors.Is(err, keg.ErrNotExist) { return fmt.Errorf("unable to read node metadata: %w", err) @@ -594,12 +595,13 @@ func (t *Tap) editMetaSchemaLocked(ctx context.Context, k keg.Keg, id keg.NodeId if err != nil { return fmt.Errorf("node metadata is invalid after editing: %w", err) } - results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: id, Schema: schema, Meta: []byte(updatedMeta.ToYAML()), HasMeta: true, LockToken: lockToken}}) + results, err := k.UpdateNodes(ctx, []keg.NodeUpdateOptions{{ID: id, Schema: schema, Meta: []byte(updatedMeta.ToYAML()), HasMeta: true, LockToken: lockToken, ExpectedHash: expectedHash}}) if err != nil { return fmt.Errorf("unable to save node metadata: %w", err) } if len(results) > 0 { t.warnSchemaValidation(results[0].Validation, id, t.Runtime.Stream()) + expectedHash = results[0].Hash } return nil }); err != nil { @@ -631,10 +633,7 @@ func logicalKegTempNameParts(k keg.Keg) (string, string) { return namespace, "keg" } if kegName != "" { - return "local", kegName - } - if strings.TrimSpace(k.Target().File) != "" { - return "local", "keg" + return "unknown", kegName } return "unknown", "keg" } diff --git a/pkg/tapper/tap_edit_format_test.go b/pkg/tapper/tap_edit_format_test.go index afe21c00..9033caf5 100644 --- a/pkg/tapper/tap_edit_format_test.go +++ b/pkg/tapper/tap_edit_format_test.go @@ -135,17 +135,6 @@ func TestEditorTempFilePrefix_MetadataUsesSameLogicalIdentity(t *testing.T) { require.Equal(t, "tap-meta-jlrickert-example-2-", got) } -func TestEditorTempFilePrefix_FileTargetDoesNotUsePathSegments(t *testing.T) { - t.Parallel() - k := kegWithTarget(&keg.Target{File: "/Users/jlrickert/kegs/example"}) - - got := editorTempFilePrefix(k, keg.NodeId{ID: 2}, "edit") - - require.Equal(t, "tap-edit-local-keg-2-", got) - require.NotContains(t, got, "jlrickert") - require.NotContains(t, got, "example") -} - func TestEditorTempFilePrefix_SanitizesUnsafeCharacters(t *testing.T) { t.Parallel() k := kegWithTarget(&keg.Target{ @@ -192,9 +181,9 @@ func TestEditorTempFilePrefix_Schema(t *testing.T) { require.Equal(t, "tap-schema-edit-jlrickert-example-task-", got) } -func TestEditorTempFilePrefix_SchemaUsesLocalHubPathIdentity(t *testing.T) { +func TestEditorTempFilePrefix_SchemaUsesOrdinaryLocalNamespaceIdentity(t *testing.T) { t.Parallel() - k := kegWithTarget(&keg.Target{File: "/home/testuser/kegs/@local/example"}) + k := kegWithTarget(&keg.Target{Namespace: "local", KegName: "example"}) got := schemaEditorTempFilePrefix(k, "task") diff --git a/pkg/tapper/tap_flight.go b/pkg/tapper/tap_flight.go index 39295f97..f74225e8 100644 --- a/pkg/tapper/tap_flight.go +++ b/pkg/tapper/tap_flight.go @@ -3,7 +3,6 @@ package tapper import ( "context" "fmt" - "path/filepath" "strings" "github.com/jlrickert/tapper/pkg/keg" @@ -33,6 +32,7 @@ type CreateFlightOptions struct { Capabilities []FlightCapability Instructions string Cover []FlightCover + Subflights []string } // UpdateFlightOptions is a partial update: nil fields keep the flight's @@ -47,10 +47,13 @@ type UpdateFlightOptions struct { Capabilities *[]FlightCapability Instructions *string Cover *[]FlightCover + Subflights *[]string + ExpectedHash string } type DeleteFlightOptions struct { - Ref string + Ref string + ExpectedHash string } // ListFlights returns canonical refs discovered across configured hubs, or @@ -65,14 +68,14 @@ func (t *Tap) GetFlight(ctx context.Context, opts GetFlightOptions) (*Flight, er } func (t *Tap) CreateFlight(ctx context.Context, opts CreateFlightOptions) (*Flight, error) { - details := FlightManifest{Visibility: opts.Visibility, Capabilities: opts.Capabilities, Cover: opts.Cover} - if err := validateFlightManifest(&details); err != nil { - return nil, err - } ref, entry, hubName, err := t.resolveWriteFlightRef(opts.Ref) if err != nil { return nil, err } + details := FlightManifest{Visibility: opts.Visibility, Capabilities: opts.Capabilities, Cover: opts.Cover, Subflights: opts.Subflights} + if err := validateFlightManifest(&details, ref.Namespace); err != nil { + return nil, err + } flight := HubFlight{ Namespace: ref.Namespace, Slug: ref.Slug, @@ -81,6 +84,7 @@ func (t *Tap) CreateFlight(ctx context.Context, opts CreateFlightOptions) (*Flig Capabilities: append([]FlightCapability{}, opts.Capabilities...), Instructions: opts.Instructions, Cover: hubCoverFromFlightCover(opts.Cover), + Subflights: append([]string(nil), opts.Subflights...), } hf, err := CreateHubFlight(ctx, entry.URL, t.FlightService.hubToken(entry), ref.Namespace, flight) if err != nil { @@ -91,6 +95,10 @@ func (t *Tap) CreateFlight(ctx context.Context, opts CreateFlightOptions) (*Flig } func (t *Tap) UpdateFlight(ctx context.Context, opts UpdateFlightOptions) (*Flight, error) { + ref, entry, hubName, err := t.resolveWriteFlightRef(opts.Ref) + if err != nil { + return nil, err + } details := FlightManifest{} if opts.Visibility != nil { details.Visibility = *opts.Visibility @@ -101,11 +109,10 @@ func (t *Tap) UpdateFlight(ctx context.Context, opts UpdateFlightOptions) (*Flig if opts.Cover != nil { details.Cover = *opts.Cover } - if err := validateFlightManifest(&details); err != nil { - return nil, err + if opts.Subflights != nil { + details.Subflights = *opts.Subflights } - ref, entry, hubName, err := t.resolveWriteFlightRef(opts.Ref) - if err != nil { + if err := validateFlightManifest(&details, ref.Namespace); err != nil { return nil, err } token := t.FlightService.hubToken(entry) @@ -129,7 +136,10 @@ func (t *Tap) UpdateFlight(ctx context.Context, opts UpdateFlightOptions) (*Flig if opts.Cover != nil { next.Cover = hubCoverFromFlightCover(*opts.Cover) } - hf, err := UpdateHubFlight(ctx, entry.URL, token, ref.Namespace, ref.Slug, next) + if opts.Subflights != nil { + next.Subflights = append([]string(nil), (*opts.Subflights)...) + } + hf, err := UpdateHubFlight(ctx, entry.URL, token, ref.Namespace, ref.Slug, next, opts.ExpectedHash) if err != nil { return nil, err } @@ -150,7 +160,7 @@ func (t *Tap) DeleteFlight(ctx context.Context, opts DeleteFlightOptions) error if err != nil { return err } - if err := DeleteHubFlight(ctx, entry.URL, t.FlightService.hubToken(entry), ref.Namespace, ref.Slug); err != nil { + if err := DeleteHubFlight(ctx, entry.URL, t.FlightService.hubToken(entry), ref.Namespace, ref.Slug, opts.ExpectedHash); err != nil { return err } t.FlightService.invalidateFlights() @@ -178,18 +188,8 @@ func (t *Tap) resolveWriteFlightRef(raw string) (FlightRef, HubEntry, string, er if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { - // Reading local manifests is fully supported — discovery, orientation, - // and cover enforcement all work off flights.d. Only mutation is - // unimplemented, so say that rather than describing it as a - // requirement the caller failed to meet. - dir, dirErr := t.FlightService.localFlightsDirFor(entry) - if dirErr != nil { - dir = "/" + flightsDirName - } - return FlightRef{}, HubEntry{}, "", fmt.Errorf( - "flight create/update/delete is not implemented for local hubs (hub %q); "+ - "write the manifest to %s/%s.yaml by hand instead", hubName, dir, ref.Slug) + if kind != HubKindRemote && kind != HubKindReadonly { + return FlightRef{}, HubEntry{}, "", fmt.Errorf("hub %q has unsupported kind %q", hubName, kind) } if strings.TrimSpace(entry.URL) == "" { return FlightRef{}, HubEntry{}, "", fmt.Errorf("hub %q has no url configured", hubName) @@ -215,10 +215,6 @@ func defaultFlightNamespace(cfg *Config) string { if ns := strings.TrimPrefix(strings.TrimSpace(entry.DefaultNamespace), "@"); ns != "" { return ns } - kind := strings.TrimSpace(entry.Kind) - if kind == HubKindLocal { - return LocalHubName - } return "" } @@ -245,30 +241,24 @@ type FlightRestrictionError struct { // flightRestrictionRecovery is appended to every cover/role-cap denial. The // direct CLI bypasses flight restrictions entirely (see applyKegTargetProfile), -// so this error only ever reaches an agent over MCP — and an agent's session -// pins its flight snapshot until it orients again. A flight edited elsewhere -// mid-session is therefore the most common cause of a denial that the reader -// believes should have succeeded, and the reader cannot discover that from a -// bare "not available" line. -const flightRestrictionRecovery = ". Call `orient` to refresh this session's flight" + - " authority: it may have changed since you oriented. If orient still does not" + - " cover this keg, the flight genuinely excludes it — ask the user to widen the" + - " flight's cover rather than retrying." +// so this error only ever reaches an agent over MCP. Authority was resolved for +// this call and the refusal never performs or replays the operation. +const flightRestrictionRecovery = ". The selected flight's current authority lacks this permission; the operation was not performed." func (e *FlightRestrictionError) Error() string { if e.Want == FlightRoleEditor && e.Got == FlightRoleViewer { - return fmt.Sprintf("keg %q is viewer-only in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery + return fmt.Sprintf("ORIENTATION_DENIED: keg %q is viewer-only in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery } if e.Want == FlightRoleAdmin && (e.Got == FlightRoleViewer || e.Got == FlightRoleEditor) { - return fmt.Sprintf("keg %q requires admin flight authority in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery + return fmt.Sprintf("ORIENTATION_DENIED: keg %q requires admin flight authority in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery } - return fmt.Sprintf("keg %q is not available in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery + return fmt.Sprintf("ORIENTATION_DENIED: keg %q is not available in flight %q", e.Keg, e.Flight) + flightRestrictionRecovery } -// enforceFlight rejects a resolved keg that falls outside the active flight's +// enforceFlight rejects a resolved keg that falls outside the selected flight's // cover or does not meet the requested role cap. A blank flight or full_access // capability bypasses the cover check; normal keg authorization still applies. -// Without full_access, an active flight with an empty cover denies every keg. +// Without full_access, a selected flight with an empty cover denies every keg. func (t *Tap) enforceFlight(ctx context.Context, flightName string, k keg.Keg, want FlightRole) error { flightName = strings.TrimSpace(flightName) if flightName == "" || k == nil { @@ -295,16 +285,6 @@ func (t *Tap) enforceFlightSnapshot(flight *Flight, k keg.Keg, want FlightRole) if k.Target() != nil { namespace = k.Target().Namespace kegName = k.Target().KegName - if namespace == "" || kegName == "" { - if localNamespace, localKegName, ok := localHubPathKegIdentity(k.Target()); ok { - if namespace == "" { - namespace = localNamespace - } - if kegName == "" { - kegName = localKegName - } - } - } if cfg, cErr := t.ConfigService.Config(); cErr == nil { alias = cfg.LookupAliasForTarget(t.Runtime, k.Target().String()) } @@ -328,44 +308,14 @@ func (t *Tap) enforceFlightSnapshot(flight *Flight, k keg.Keg, want FlightRole) } // CanonicalKegRef returns the @namespace/keg reference for a resolved target, -// or "" when the target names no namespaced keg. A filesystem keg carries its -// identity in the path rather than in the Namespace/KegName fields, so this -// applies the same derivation enforceFlightSnapshot uses — callers reporting a -// keg back to an agent must name it the way the flight cover does. +// or "" when the target names no namespaced keg. func CanonicalKegRef(target *keg.Target) string { if target == nil { return "" } namespace, kegName := target.Namespace, target.KegName - if namespace == "" || kegName == "" { - if pathNamespace, pathKeg, ok := localHubPathKegIdentity(target); ok { - if namespace == "" { - namespace = pathNamespace - } - if kegName == "" { - kegName = pathKeg - } - } - } if namespace == "" || kegName == "" { return "" } return "@" + namespace + "/" + kegName } - -func localHubPathKegIdentity(target *keg.Target) (string, string, bool) { - if target == nil { - return "", "", false - } - file := strings.TrimSpace(target.File) - if file == "" { - return "", "", false - } - clean := filepath.Clean(file) - kegName := strings.TrimSpace(filepath.Base(clean)) - parent := strings.TrimSpace(filepath.Base(filepath.Dir(clean))) - if strings.HasPrefix(parent, "@") && len(parent) > 1 && kegName != "" && kegName != "." { - return strings.TrimPrefix(parent, "@"), kegName, true - } - return "", "", false -} diff --git a/pkg/tapper/tap_flight_edit.go b/pkg/tapper/tap_flight_edit.go index 10f3f814..e566c7eb 100644 --- a/pkg/tapper/tap_flight_edit.go +++ b/pkg/tapper/tap_flight_edit.go @@ -9,12 +9,14 @@ import ( "strings" "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/pkg/schemas" "gopkg.in/yaml.v3" ) // EditFlightOptions configures behavior for Tap.EditFlight. type EditFlightOptions struct { - Ref string + Ref string + ExpectedHash string // Stream, when piped with non-empty content, supplies the manifest YAML // directly so scripts can apply a full manifest without an editor. @@ -42,15 +44,19 @@ func (t *Tap) EditFlight(ctx context.Context, opts EditFlightOptions) (*Flight, return nil, err } currentFlight := flightFromHub(*current, hubName) - manifestRaw, err := renderFlightManifestEditorDocument(ref, currentFlight.FlightManifest) + manifestRaw, err := renderFlightManifestEditorDocument(t.Runtime, ref, currentFlight.FlightManifest) if err != nil { return nil, fmt.Errorf("unable to render flight manifest: %w", err) } result := currentFlight lastManifest := currentFlight.FlightManifest + expectedHash := opts.ExpectedHash + if (opts.Stream == nil || !opts.Stream.IsPiped) && expectedHash == "" { + expectedHash = currentFlight.ManifestHash + } apply := func(raw []byte) (*Flight, error) { - m, err := parseFlightManifestStrict(raw) + m, err := parseFlightManifestStrict(raw, ref.Namespace) if err != nil { return nil, err } @@ -65,14 +71,16 @@ func (t *Tap) EditFlight(ctx context.Context, opts EditFlightOptions) (*Flight, Capabilities: append([]FlightCapability{}, m.Capabilities...), Instructions: m.Instructions, Cover: hubCoverFromFlightCover(m.Cover), + Subflights: append([]string(nil), m.Subflights...), } - hf, err := UpdateHubFlight(ctx, entry.URL, token, ref.Namespace, ref.Slug, next) + hf, err := UpdateHubFlight(ctx, entry.URL, token, ref.Namespace, ref.Slug, next, expectedHash) if err != nil { return nil, err } t.FlightService.invalidateFlights() result = flightFromHub(*hf, hubName) lastManifest = result.FlightManifest + expectedHash = result.ManifestHash return result, nil } @@ -122,16 +130,18 @@ type flightManifestEditorDocument struct { Visibility string `yaml:"visibility"` Capabilities []FlightCapability `yaml:"capabilities"` Cover []FlightCover `yaml:"cover"` + Subflights []string `yaml:"subflights"` Instructions string `yaml:"instructions"` } -func renderFlightManifestEditorDocument(ref FlightRef, m FlightManifest) ([]byte, error) { +func renderFlightManifestEditorDocument(rt *toolkit.Runtime, ref FlightRef, m FlightManifest) ([]byte, error) { canonical := canonicalFlightManifest(m) doc := flightManifestEditorDocument{ Title: canonical.Title, Visibility: canonical.Visibility, Capabilities: append([]FlightCapability{}, canonical.Capabilities...), Cover: canonical.Cover, + Subflights: canonical.Subflights, Instructions: canonical.Instructions, } body, err := yaml.Marshal(doc) @@ -140,7 +150,7 @@ func renderFlightManifestEditorDocument(ref FlightRef, m FlightManifest) ([]byte } var out bytes.Buffer - out.WriteString(flightManifestSchemaModeline) + out.WriteString(schemas.Modeline(rt, schemas.FlightManifest)) fmt.Fprintf(&out, "# Flight %s. Ref is immutable; edit title, visibility, capabilities, cover, and instructions.\n", ref.Canonical()) out.Write(body) if !bytes.HasSuffix(out.Bytes(), []byte("\n")) { @@ -154,6 +164,7 @@ type comparableFlightManifest struct { Visibility string Capabilities []FlightCapability Cover []FlightCover + Subflights []string Instructions string } @@ -178,6 +189,7 @@ func canonicalFlightManifest(m FlightManifest) comparableFlightManifest { Visibility: m.Visibility, Capabilities: append([]FlightCapability{}, m.Capabilities...), Cover: cover, + Subflights: append([]string(nil), m.Subflights...), Instructions: m.Instructions, } } @@ -185,7 +197,7 @@ func canonicalFlightManifest(m FlightManifest) comparableFlightManifest { func flightManifestSemanticallyEqual(a, b FlightManifest) bool { ca := canonicalFlightManifest(a) cb := canonicalFlightManifest(b) - if ca.Title != cb.Title || ca.Visibility != cb.Visibility || ca.Instructions != cb.Instructions || len(ca.Capabilities) != len(cb.Capabilities) || len(ca.Cover) != len(cb.Cover) { + if ca.Title != cb.Title || ca.Visibility != cb.Visibility || ca.Instructions != cb.Instructions || len(ca.Capabilities) != len(cb.Capabilities) || len(ca.Cover) != len(cb.Cover) || len(ca.Subflights) != len(cb.Subflights) { return false } for i := range ca.Capabilities { @@ -198,13 +210,18 @@ func flightManifestSemanticallyEqual(a, b FlightManifest) bool { return false } } + for i := range ca.Subflights { + if ca.Subflights[i] != cb.Subflights[i] { + return false + } + } return true } // parseFlightManifestStrict decodes an edited manifest, rejecting unknown // keys (so a slug change attempt fails loudly) and unknown cover roles. // An omitted role keeps the manifest parser's viewer default. -func parseFlightManifestStrict(raw []byte) (*FlightManifest, error) { +func parseFlightManifestStrict(raw []byte, namespace string) (*FlightManifest, error) { var m FlightManifest dec := yaml.NewDecoder(bytes.NewReader(raw)) dec.KnownFields(true) @@ -218,7 +235,7 @@ func parseFlightManifestStrict(raw []byte) (*FlightManifest, error) { return nil, fmt.Errorf("invalid flight cover role %q", c.Role) } } - if err := validateFlightManifest(&m); err != nil { + if err := validateFlightManifest(&m, namespace); err != nil { return nil, err } normalizeFlightManifest(&m) diff --git a/pkg/tapper/tap_flight_edit_test.go b/pkg/tapper/tap_flight_edit_test.go index 8b50baad..dfa45135 100644 --- a/pkg/tapper/tap_flight_edit_test.go +++ b/pkg/tapper/tap_flight_edit_test.go @@ -13,6 +13,7 @@ import ( "github.com/jlrickert/cli-toolkit/sandbox" "github.com/jlrickert/cli-toolkit/toolkit" + "github.com/jlrickert/tapper/pkg/schemas" "github.com/jlrickert/tapper/pkg/tapper" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" @@ -295,7 +296,10 @@ func TestEditFlight_EditorStartsWithSchemaBackedManifest(t *testing.T) { raw, err := os.ReadFile(capturePath) require.NoError(t, err) opened := string(raw) - require.True(t, strings.HasPrefix(opened, "# yaml-language-server: $schema="+tapper.FlightManifestSchemaURL+"\n")) + // The modeline points at the schema this binary shipped, not at whatever + // is published on main. + require.True(t, strings.HasPrefix(opened, + schemas.ModelinePrefix+schemas.ModelineURI(fx.Runtime(), schemas.FlightManifest)+"\n"), "got: %s", opened) require.Contains(t, opened, "# Flight @foldwise/+agent-work. Ref is immutable; edit title, visibility, capabilities, cover, and instructions.") require.Contains(t, opened, `title: ""`) require.Contains(t, opened, `visibility: private`) diff --git a/pkg/tapper/tap_graph.go b/pkg/tapper/tap_graph.go deleted file mode 100644 index ac53486d..00000000 --- a/pkg/tapper/tap_graph.go +++ /dev/null @@ -1,387 +0,0 @@ -package tapper - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" -) - -// GraphOptions configures graph HTML generation for a resolved keg. -type GraphOptions struct { - KegTargetOptions - - // BundleJS is the compiled browser renderer injected into the generated page. - BundleJS []byte -} - -type graphPayload struct { - Nodes []graphNode `json:"nodes"` - Edges []graphEdge `json:"edges"` -} - -type graphNode struct { - ID string `json:"id"` - Label string `json:"label"` - Summary string `json:"summary"` - Tags []string `json:"tags"` - URL string `json:"url"` -} - -type graphEdge struct { - Source string `json:"source"` - Target string `json:"target"` - Type string `json:"type"` -} - -const graphFallbackBundle = `(() => { - const app = document.getElementById("app"); - if (!app) return; - app.innerHTML = "
Graph bundle is missing. Rebuild assets.
"; -})();` - -// Graph renders a self-contained HTML page for the resolved keg graph. -func (t *Tap) Graph(ctx context.Context, opts GraphOptions) (string, error) { - k, err := t.resolveKeg(ctx, opts.KegTargetOptions) - if err != nil { - return "", fmt.Errorf("unable to open keg: %w", err) - } - view, err := k.Graph(ctx) - if err != nil { - return "", fmt.Errorf("unable to read graph: %w", err) - } - payload := graphPayload{Nodes: make([]graphNode, 0, len(view.Nodes)), Edges: make([]graphEdge, 0, len(view.Edges))} - for _, node := range view.Nodes { - label := node.Title - if strings.TrimSpace(label) == "" { - label = node.ID - } - payload.Nodes = append(payload.Nodes, graphNode{ID: node.ID, Label: label, Summary: node.Lead, Tags: node.Tags}) - } - for _, edge := range view.Edges { - payload.Edges = append(payload.Edges, graphEdge{Source: edge.Source, Target: edge.Target, Type: edge.Type}) - } - bundle := opts.BundleJS - if len(strings.TrimSpace(string(bundle))) == 0 { - bundle = []byte(graphFallbackBundle) - } - - out, err := renderGraphHTML(payload, bundle) - if err != nil { - return "", err - } - return out, nil -} - -func buildGraphPayload(ctx context.Context, rt *toolkit.Runtime, k keg.Keg, dex *keg.Dex) graphPayload { - payload := graphPayload{ - Nodes: []graphNode{}, - Edges: []graphEdge{}, - } - if dex == nil { - return payload - } - - tagsByNode := graphTagsByNode(ctx, dex) - nodeByID := map[string]graphNode{} - - entries := dex.Nodes(ctx) - for _, entry := range entries { - id := strings.TrimSpace(entry.ID) - if id == "" { - continue - } - - label := strings.TrimSpace(entry.Title) - if label == "" { - label = id - } - - node := graphNode{ - ID: id, - Label: label, - Summary: "", - Tags: tagsByNode[id], - URL: "", - } - if parsed, err := keg.ParseNode(id); err == nil && parsed != nil { - node.Summary = readNodeSummary(ctx, rt, k, *parsed) - } - nodeByID[id] = node - } - - edgeSeen := map[string]struct{}{} - for _, entry := range entries { - id := strings.TrimSpace(entry.ID) - if id == "" { - continue - } - src, err := keg.ParseNode(id) - if err != nil || src == nil { - continue - } - - if links, ok := dex.Links(ctx, *src); ok { - for _, dst := range links { - addEdgeAndNode(&payload, edgeSeen, nodeByID, graphEdge{ - Source: src.Path(), - Target: dst.Path(), - Type: "link", - }) - } - } - - if backlinks, ok := dex.Backlinks(ctx, *src); ok { - for _, source := range backlinks { - addEdgeAndNode(&payload, edgeSeen, nodeByID, graphEdge{ - Source: src.Path(), - Target: source.Path(), - Type: "backlink", - }) - } - } - } - - payload.Nodes = make([]graphNode, 0, len(nodeByID)) - for _, node := range nodeByID { - payload.Nodes = append(payload.Nodes, node) - } - sortGraphNodes(payload.Nodes) - sortGraphEdges(payload.Edges) - return payload -} - -func addEdgeAndNode(payload *graphPayload, seen map[string]struct{}, nodeByID map[string]graphNode, edge graphEdge) { - if payload == nil { - return - } - if edge.Source == "" || edge.Target == "" || edge.Type == "" { - return - } - key := edge.Source + "\x00" + edge.Target + "\x00" + edge.Type - if _, ok := seen[key]; !ok { - seen[key] = struct{}{} - payload.Edges = append(payload.Edges, edge) - } - if _, ok := nodeByID[edge.Source]; !ok { - nodeByID[edge.Source] = graphNode{ - ID: edge.Source, - Label: edge.Source, - Summary: "", - Tags: nil, - URL: "", - } - } - if _, ok := nodeByID[edge.Target]; !ok { - nodeByID[edge.Target] = graphNode{ - ID: edge.Target, - Label: edge.Target, - Summary: "", - Tags: nil, - URL: "", - } - } -} - -func readNodeSummary(ctx context.Context, rt *toolkit.Runtime, k keg.Keg, id keg.NodeId) string { - if k == nil || rt == nil { - return "" - } - if stats, err := k.GetStats(ctx, id); err == nil { - if lead := compactWhitespace(stats.Lead()); lead != "" { - return lead - } - } else if !errors.Is(err, keg.ErrNotExist) { - return "" - } - - raw, err := k.GetContent(ctx, id) - if err != nil { - return "" - } - content, err := keg.ParseContent(rt, raw, keg.FormatMarkdown) - if err != nil || content == nil { - return "" - } - return compactWhitespace(content.Lead) -} - -func graphTagsByNode(ctx context.Context, dex *keg.Dex) map[string][]string { - out := map[string][]string{} - if dex == nil { - return out - } - - tags := dex.TagList(ctx) - sort.Strings(tags) - for _, tag := range tags { - nodes, ok := dex.TagNodes(ctx, tag) - if !ok { - continue - } - for _, node := range nodes { - key := node.Path() - if key == "" { - continue - } - out[key] = append(out[key], tag) - } - } - - for id, tags := range out { - if len(tags) <= 1 { - continue - } - sort.Strings(tags) - dedup := tags[:0] - for _, tag := range tags { - if len(dedup) == 0 || dedup[len(dedup)-1] != tag { - dedup = append(dedup, tag) - } - } - out[id] = dedup - } - - return out -} - -func compactWhitespace(raw string) string { - parts := strings.Fields(strings.TrimSpace(raw)) - if len(parts) == 0 { - return "" - } - return strings.Join(parts, " ") -} - -func sortGraphNodes(nodes []graphNode) { - sort.SliceStable(nodes, func(i, j int) bool { - return compareNodePath(nodes[i].ID, nodes[j].ID) < 0 - }) -} - -func sortGraphEdges(edges []graphEdge) { - sort.SliceStable(edges, func(i, j int) bool { - if cmp := compareNodePath(edges[i].Source, edges[j].Source); cmp != 0 { - return cmp < 0 - } - if cmp := compareNodePath(edges[i].Target, edges[j].Target); cmp != 0 { - return cmp < 0 - } - return edges[i].Type < edges[j].Type - }) -} - -func compareNodePath(a, b string) int { - na, ea := keg.ParseNode(a) - nb, eb := keg.ParseNode(b) - - switch { - case ea == nil && na != nil && eb == nil && nb != nil: - return na.Compare(*nb) - case ea == nil && na != nil: - return -1 - case eb == nil && nb != nil: - return 1 - } - - if a < b { - return -1 - } - if a > b { - return 1 - } - return 0 -} - -func renderGraphHTML(payload graphPayload, bundle []byte) (string, error) { - graphJSON, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("unable to marshal graph payload: %w", err) - } - - escapedBundle := strings.ReplaceAll(string(bundle), "", "<\\/script>") - escapedBundle = strings.ReplaceAll(escapedBundle, "", "<\\/SCRIPT>") - - out := fmt.Sprintf(` - - - - - KEG Graph - - - -
- - - - -`, string(graphJSON), escapedBundle) - - return out, nil -} diff --git a/pkg/tapper/tap_hub.go b/pkg/tapper/tap_hub.go index 517c7e9e..e4262970 100644 --- a/pkg/tapper/tap_hub.go +++ b/pkg/tapper/tap_hub.go @@ -5,11 +5,9 @@ import ( "errors" "fmt" "os" - "path/filepath" "sort" "strings" - "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" ) @@ -20,8 +18,7 @@ type HubListOptions struct { } // HubListKegs lists kegs qualified as "@namespace/keg". With no --hub it -// aggregates across every configured hub: local hubs are scanned on disk at -// /@/; remote/readonly hubs are queried via the hub's +// aggregates across every configured remote/readonly hub via the hub's // GET /api/v1/kegs, which returns the kegs the authenticated user can reach // (namespace membership + grants). With an explicit --hub only that hub is // listed and its errors surface directly; in aggregate mode an unreachable or @@ -74,12 +71,11 @@ func (t *Tap) HubListKegs(ctx context.Context, opts HubListOptions) ([]string, e } // allHubNames returns the names of every hub to enumerate in aggregate mode: -// the configured hubs, or — when none are configured — the built-in local and -// default hubs the config synthesizes. +// the configured hubs, or the built-in default hub when none are configured. func (t *Tap) allHubNames(cfg *Config) []string { hubs := cfg.Hubs() if len(hubs) == 0 { - return dedupeStrings([]string{cfg.localHubName(), cfg.resolveHubName()}) + return dedupeStrings([]string{cfg.resolveHubName()}) } names := make([]string, 0, len(hubs)) for n := range hubs { @@ -89,20 +85,14 @@ func (t *Tap) allHubNames(cfg *Config) []string { return names } -// listHubKegs returns the kegs on a single hub as "@namespace/keg". Local hubs -// scan the filesystem; remote/readonly hubs query GET /api/v1/kegs with the -// hub's resolved bearer token. +// listHubKegs returns the kegs on a single remote hub as "@namespace/keg". func (t *Tap) listHubKegs(ctx context.Context, name string, entry HubEntry) ([]string, error) { kind := strings.TrimSpace(entry.Kind) if kind == "" { kind = HubKindRemote } - if kind == HubKindLocal { - base, err := t.localHubBase(entry) - if err != nil { - return nil, err - } - return t.scanLocalHubKegs(base), nil + if kind != HubKindRemote && kind != HubKindReadonly { + return nil, fmt.Errorf("hub %q has unsupported kind %q", name, kind) } url := strings.TrimSpace(entry.URL) @@ -124,25 +114,6 @@ func (t *Tap) listHubKegs(ctx context.Context, name string, entry HubEntry) ([]s return out, nil } -// localHubBase resolves a local hub's on-disk base directory, defaulting to the -// platform user keg root when the entry has no basePath, then expanding env -// vars and a leading tilde. -func (t *Tap) localHubBase(entry HubEntry) (string, error) { - base := strings.TrimSpace(entry.BasePath) - if base == "" { - root, err := defaultUserKegRoot(t.Runtime) - if err != nil { - return "", err - } - base = root - } - base = toolkit.ExpandEnv(t.Runtime, base) - if expanded, err := toolkit.ExpandPath(t.Runtime, base); err == nil { - base = expanded - } - return base, nil -} - // hubToken resolves the bearer token for a configured remote hub. It builds a // synthetic target from the hub entry and defers to hubTokenForTarget. func (t *Tap) hubToken(entry HubEntry) string { @@ -377,40 +348,3 @@ func dedupeStrings(s []string) []string { } return out } - -// scanLocalHubKegs walks /@/ and returns every directory -// that is a keg (carries a keg config file), formatted as "@namespace/keg". -func (t *Tap) scanLocalHubKegs(base string) []string { - nsEntries, err := t.Runtime.ReadDir(base) - if err != nil { - return []string{} // a missing base means no kegs, not an error - } - var out []string - for _, nsE := range nsEntries { - if !nsE.IsDir() || !strings.HasPrefix(nsE.Name(), "@") { - continue - } - ns := strings.TrimPrefix(nsE.Name(), "@") - nsDir := filepath.Join(base, nsE.Name()) - kegEntries, readErr := t.Runtime.ReadDir(nsDir) - if readErr != nil { - continue - } - for _, kE := range kegEntries { - if kE.IsDir() && t.isKegDir(filepath.Join(nsDir, kE.Name())) { - out = append(out, "@"+ns+"/"+kE.Name()) - } - } - } - sort.Strings(out) - return out -} - -func (t *Tap) isKegDir(dir string) bool { - for _, name := range []string{"keg", "keg.yaml", "keg.yml"} { - if _, err := t.Runtime.Stat(filepath.Join(dir, name), false); err == nil { - return true - } - } - return false -} diff --git a/pkg/tapper/tap_hub_test.go b/pkg/tapper/tap_hub_test.go index 6f83b918..0a345624 100644 --- a/pkg/tapper/tap_hub_test.go +++ b/pkg/tapper/tap_hub_test.go @@ -11,33 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestHubListKegs_LocalScan(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), - []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644)) - - // Two kegs in two namespaces (a keg dir is one carrying a "keg" config file). - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/notes/keg", []byte("kegv: 2023-01\n"), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/@work/blog/keg", []byte("kegv: 2023-01\n"), 0o644)) - // A directory that is not a keg (no keg config) must be ignored. - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/scratch/notes.txt", []byte("x"), 0o644)) - // flights.d sits beside the namespaces and must never be listed as a keg. - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: B\n"), 0o644)) - - kegs, err := tap.HubListKegs(fx.Context(), tapper.HubListOptions{}) - require.NoError(t, err) - require.Equal(t, []string{"@local/notes", "@work/blog"}, kegs) -} - func TestHubListKegs_RemoteRequiresAuth(t *testing.T) { t.Parallel() fx := NewSandbox(t) @@ -80,15 +53,12 @@ func TestHubListKegs_RemoteAggregates(t *testing.T) { }) require.NoError(t, err) - // One local hub (filesystem) + one remote hub (httptest, inline token). - // HubListKegs with no --hub aggregates both. + // HubListKegs with no --hub aggregates configured remote hubs. cfg := fmt.Sprintf("hubs:\n"+ - " home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"+ " atlas:\n kind: remote\n url: %s\n token: remote-tok\n", srv.URL) require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/notes/keg", []byte("kegv: 2023-01\n"), 0o644)) kegs, err := tap.HubListKegs(fx.Context(), tapper.HubListOptions{}) require.NoError(t, err) - require.Equal(t, []string{"@jlrickert/example", "@local/notes", "@shared/docs"}, kegs) + require.Equal(t, []string{"@jlrickert/example", "@shared/docs"}, kegs) } diff --git a/pkg/tapper/tap_import.go b/pkg/tapper/tap_import.go index b5b57d9e..f45caa72 100644 --- a/pkg/tapper/tap_import.go +++ b/pkg/tapper/tap_import.go @@ -103,21 +103,36 @@ func (t *Tap) ImportFromKeg(ctx context.Context, opts ImportFromKegOptions) ([]I // Write forwarding stubs at source locations if requested. if opts.LeaveStubs && tgtAlias != "" { - redirects := make([]keg.NodeRedirect, 0, len(imported)) + updates := make([]keg.NodeUpdateOptions, 0, len(imported)) for _, node := range imported { srcID, parseErr := keg.ParseNode(node.SourceID) if parseErr != nil || srcID == nil { continue } - redirects = append(redirects, keg.NodeRedirect{ID: *srcID, Target: "keg:" + tgtAlias, TargetID: node.ID, ExpectedHash: node.SourceHash}) + view, readErr := srcKeg.ReadNode(ctx, *srcID) + if readErr != nil { + return nil, fmt.Errorf("unable to read source node %s before writing forwarding stub: %w", srcID.Path(), readErr) + } + title := "" + if view.Stats != nil { + title = strings.TrimSpace(view.Stats.Title()) + } + if title == "" { + if parsed, parseContentErr := keg.ParseContent(t.Runtime, view.Content, keg.MarkdownContentFilename); parseContentErr == nil { + title = strings.TrimSpace(parsed.Title) + } + } + if title == "" { + title = srcID.Path() + } + target := "keg:" + tgtAlias + body := fmt.Sprintf("# %s\n\nMoved to [%s/%s](%s/%s).\n", title, target, node.ID.Path(), target, node.ID.Path()) + updates = append(updates, keg.NodeUpdateOptions{ID: *srcID, Content: []byte(body), HasContent: true, ExpectedHash: node.SourceHash}) } - result, err := srcKeg.ReplaceNodesWithRedirects(ctx, redirects) + _, err := srcKeg.UpdateNodes(keg.WithValidationMode(ctx, keg.ValidationModeOff), updates) if err != nil { return nil, fmt.Errorf("unable to write forwarding stubs: %w", err) } - if result.Failure != nil { - return nil, fmt.Errorf("unable to write forwarding stub for node %s: %w", result.Failure.NodeID.Path(), result.Failure.Err()) - } } result := make([]ImportedNode, 0, len(imported)) diff --git a/pkg/tapper/tap_import_test.go b/pkg/tapper/tap_import_test.go index 4f035fb0..def90968 100644 --- a/pkg/tapper/tap_import_test.go +++ b/pkg/tapper/tap_import_test.go @@ -9,6 +9,18 @@ import ( "github.com/stretchr/testify/require" ) +type updateRecordingKeg struct { + keg.Keg + batches [][]keg.NodeUpdateOptions + modes []keg.ValidationMode +} + +func (k *updateRecordingKeg) UpdateNodes(ctx context.Context, updates []keg.NodeUpdateOptions) ([]keg.NodeUpdateResult, error) { + k.batches = append(k.batches, append([]keg.NodeUpdateOptions(nil), updates...)) + k.modes = append(k.modes, keg.ValidationModeFromContext(ctx)) + return k.Keg.UpdateNodes(ctx, updates) +} + func TestResolveImportSourceAlias_BareIDs(t *testing.T) { t.Parallel() alias, bareIDs, err := resolveImportSourceAlias([]string{"1", "2", "3"}, "mykeg") diff --git a/pkg/tapper/tap_info.go b/pkg/tapper/tap_info.go index d4a980ef..ff22c274 100644 --- a/pkg/tapper/tap_info.go +++ b/pkg/tapper/tap_info.go @@ -4,15 +4,13 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" - "os" - "path/filepath" "strings" "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/schemas" "gopkg.in/yaml.v3" ) @@ -59,32 +57,21 @@ func (t *Tap) KegSettings(ctx context.Context, opts KegSettingsOptions) (string, return t.kegSettingsMinimal(ctx, k) } - // For file-backed kegs, return the raw config contents so unknown sections - // (for example custom fields and entities) are preserved. - if k.Target() != nil && k.Target().Scheme() == keg.SchemeFile { - raw, rawErr := readRawKegConfig(t.Runtime, k.Target().Path()) - if rawErr == nil { - return string(raw), nil - } - if !os.IsNotExist(rawErr) { - return "", fmt.Errorf("unable to read raw keg config: %w", rawErr) - } - } - - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { - return "", fmt.Errorf("unable to read keg config: %w", err) + return "", fmt.Errorf("unable to read keg settings: %w", err) } - // Convert config to YAML format - return cfg.String(), nil + // Settings transports retain the original document so extensions and + // unknown fields survive a remote read/edit/write cycle. + return string(cfg.Raw()), nil } -// kegSettingsMinimal returns a compact keg config with only core fields. +// kegSettingsMinimal returns a compact keg settings with only core fields. func (t *Tap) kegSettingsMinimal(ctx context.Context, k keg.Keg) (string, error) { - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { - return "", fmt.Errorf("unable to read keg config: %w", err) + return "", fmt.Errorf("unable to read keg settings: %w", err) } type minimalConfig struct { @@ -139,119 +126,31 @@ func (t *Tap) kegSettingsBatch(ctx context.Context, opts KegSettingsOptions) (st refs = append(refs, ref) } - if t.OrientationDetailsResolver != nil { - details, err := t.OrientationDetailsResolver(ctx, refs) + details := make([]minimalKegSettings, 0, len(refs)) + for _, ref := range refs { + detail, err := t.readMinimalKegSettings(ctx, opts, ref) if err != nil { + // Do not serialize until every ordinary settings read succeeds, so + // callers never receive a partial batch. return "", err } - return marshalMinimalKegSettings(refs, details) - } - - cfg, err := t.ConfigService.Config() - if err != nil { - return "", err - } - type selection struct { - index int - ref string - namespace string - alias string - hub string - entry HubEntry - } - type group struct { - hub string - entry HubEntry - selections []selection - } - groupIndexes := map[string]int{} - var groups []group - for i, ref := range refs { - namespace, alias, _ := parseCanonicalKegSelection(ref) - _, hubName, entry, resolveErr := cfg.resolveNamespaceHub(namespace, "") - if resolveErr != nil { - return "", resolveErr - } - sel := selection{ - index: i, - ref: ref, - namespace: namespace, - alias: alias, - hub: hubName, - entry: entry, - } - groupIndex, ok := groupIndexes[hubName] - if !ok { - groupIndex = len(groups) - groupIndexes[hubName] = groupIndex - groups = append(groups, group{hub: hubName, entry: entry}) - } - groups[groupIndex].selections = append(groups[groupIndex].selections, sel) - } - - details := make([]HubOrientationDetail, len(refs)) - for _, grouped := range groups { - kind := hubKindOrDefault(grouped.entry.Kind) - if kind == HubKindLocal { - for _, sel := range grouped.selections { - detail, detailErr := t.readOrientationDetail(ctx, opts, sel.ref) - if detailErr != nil { - return "", detailErr - } - details[sel.index] = detail - } - continue - } - url := strings.TrimSpace(grouped.entry.URL) - if url == "" { - return "", fmt.Errorf("hub %q has no url configured", grouped.hub) - } - token := t.hubToken(grouped.entry) - if token == "" { - return "", fmt.Errorf("hub %q has no authenticated session for %s", grouped.hub, url) - } - groupRefs := make([]string, 0, len(grouped.selections)) - for _, sel := range grouped.selections { - groupRefs = append(groupRefs, sel.ref) - } - groupDetails, fetchErr := FetchOrientationDetails(ctx, url, token, groupRefs) - if errors.Is(fetchErr, ErrOrientationUnsupported) { - groupDetails = nil - for _, sel := range grouped.selections { - detail, detailErr := t.readOrientationDetail(ctx, opts, sel.ref) - if detailErr != nil { - return "", detailErr - } - groupDetails = append(groupDetails, detail) - } - } else if fetchErr != nil { - return "", fetchErr - } - if len(groupDetails) != len(grouped.selections) { - return "", fmt.Errorf("hub %q returned incomplete orientation details", grouped.hub) - } - for i, sel := range grouped.selections { - if groupDetails[i].Keg != sel.ref { - return "", fmt.Errorf("hub %q returned orientation details out of order", grouped.hub) - } - details[sel.index] = groupDetails[i] - } + details = append(details, detail) } return marshalMinimalKegSettings(refs, details) } -func (t *Tap) readOrientationDetail(ctx context.Context, opts KegSettingsOptions, ref string) (HubOrientationDetail, error) { +func (t *Tap) readMinimalKegSettings(ctx context.Context, opts KegSettingsOptions, ref string) (minimalKegSettings, error) { targetOpts := opts.KegTargetOptions targetOpts.Keg = ref k, err := t.resolveKeg(ctx, targetOpts) if err != nil { - return HubOrientationDetail{}, fmt.Errorf("unable to open keg %q: %w", ref, err) + return minimalKegSettings{}, fmt.Errorf("unable to open keg %q: %w", ref, err) } - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil { - return HubOrientationDetail{}, fmt.Errorf("unable to read keg config %q: %w", ref, err) + return minimalKegSettings{}, fmt.Errorf("unable to read keg settings %q: %w", ref, err) } - return HubOrientationDetail{ + return minimalKegSettings{ Keg: ref, Title: cfg.Title, Summary: cfg.Summary, @@ -260,14 +159,14 @@ func (t *Tap) readOrientationDetail(ctx context.Context, opts KegSettingsOptions }, nil } -func marshalMinimalKegSettings(refs []string, details []HubOrientationDetail) (string, error) { +func marshalMinimalKegSettings(refs []string, details []minimalKegSettings) (string, error) { if len(details) != len(refs) { - return "", fmt.Errorf("orientation details response length does not match request") + return "", fmt.Errorf("settings response length does not match request") } out := make([]minimalKegSettings, len(refs)) for i, ref := range refs { if details[i].Keg != ref { - return "", fmt.Errorf("orientation details response does not preserve request order") + return "", fmt.Errorf("settings response does not preserve request order") } out[i] = minimalKegSettings{ Keg: ref, @@ -348,14 +247,11 @@ func (t *Tap) resolveIdentity(opts KegTargetOptions) resolvedIdentity { if selector != "" { ref, oErr := applyRefOverrides(parseKegRef(selector), opts.Namespace, opts.Hub, selector) if oErr == nil { - if ref.Path != "" { - id.Ref = ref.Path - } else { + if ref.Name != "" { id.Keg = ref.Name // Infer namespace + hub through the shared chain so a bare name - // (e.g. "private") displays as "@local/private" on a local hub, - // matching what the backend actually resolves. Best-effort: if it - // cannot resolve (e.g. a remote hub with no namespace), fall back + // displays as its fully qualified remote reference. Best-effort: if + // it cannot resolve (for example, a hub with no namespace), fall back // to the bare name and leave namespace/hub blank. if ns, hub, entry, rErr := cfg.resolveNamespaceHub(ref.Namespace, ref.Hub); rErr == nil { id.Namespace = ns @@ -404,7 +300,7 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { } info, err := k.Info(ctx) if err != nil { - return "", fmt.Errorf("unable to read keg config: %w", err) + return "", fmt.Errorf("unable to read keg settings: %w", err) } summary := info.Summary @@ -446,9 +342,9 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { Images: capability{Supported: summary.Images.Supported}, } - // Populate summary from the keg config. - if info.Config != nil && info.Config.Summary != "" { - out.Summary = info.Config.Summary + // Populate summary from the keg settings. + if info.Settings != nil && info.Settings.Summary != "" { + out.Summary = info.Settings.Summary } if opts.Debug { @@ -472,15 +368,7 @@ func (t *Tap) Info(ctx context.Context, opts InfoOptions) (string, error) { if out.Ref == "" { out.Ref = canonicalKegRef(kegRefLabel(k.Target())) } - if k.Target().Scheme() == keg.SchemeFile { - path := toolkit.ExpandEnv(t.Runtime, k.Target().Path()) - if expanded, expandErr := toolkit.ExpandPath(t.Runtime, path); expandErr == nil { - path = expanded - } - debug.KegDirectory = filepath.Clean(path) - } else { - debug.KegDirectory = k.Target().Path() - } + debug.KegDirectory = k.Target().Path() } out.Debug = debug } else if out.Ref == "" && k.Target() != nil { @@ -513,103 +401,76 @@ func canonicalKegRef(ref string) string { return ref } -func readRawKegConfig(rt *toolkit.Runtime, root string) ([]byte, error) { - _, raw, err := readRawKegConfigWithPath(rt, root) - return raw, err +// KegSettingsEditOptions configures behavior for Tap.KegSettingsEdit. +type KegSettingsEditOptions struct { + KegTargetOptions + Stream *toolkit.Stream + ExpectedHash string } -func readRawKegConfigWithPath(rt *toolkit.Runtime, root string) (string, []byte, error) { - base := toolkit.ExpandEnv(rt, root) - if expanded, err := toolkit.ExpandPath(rt, base); err == nil { - base = expanded - } - - var firstErr error - for _, name := range []string{"keg", "keg.yaml", "keg.yml"} { - path := filepath.Join(base, name) - if resolved, err := rt.ResolvePath(path, true); err == nil { - path = resolved - } - - data, err := rt.ReadFile(path) - if err == nil { - return path, data, nil - } - if os.IsNotExist(err) { - continue - } - if firstErr == nil { - firstErr = err - } +// KegSettingsHash performs the read half of an explicit CLI read-before-write +// flow. Mutation methods never call it implicitly. +func (t *Tap) KegSettingsHash(ctx context.Context, opts KegTargetOptions) (string, error) { + k, err := t.resolveKegForRole(ctx, opts, FlightRoleViewer) + if err != nil { + return "", err } - - if firstErr != nil { - return "", nil, firstErr + cfg, err := k.Settings(ctx) + if err != nil { + return "", err } - return "", nil, os.ErrNotExist + return cfg.Hash(), nil } -// KegConfigEditOptions configures behavior for Tap.KegConfigEdit. -type KegConfigEditOptions struct { - KegTargetOptions - Stream *toolkit.Stream -} - -// KegConfigEdit opens the keg configuration file in the default editor. -func (t *Tap) KegConfigEdit(ctx context.Context, opts KegConfigEditOptions) error { - k, err := t.resolveKegForRoles(ctx, opts.KegTargetOptions, FlightRoleEditor, FlightRoleAdmin) +// KegSettingsEdit opens the keg settings file in the default editor. +// +// Replacing the settings document is keg administration, so it requires admin +// on the keg itself and not merely admin on the flight. Asking for editor +// identity access here previously let a flightless session — which has no flight +// authority to check — perform the write with editor access alone. +func (t *Tap) KegSettingsEdit(ctx context.Context, opts KegSettingsEditOptions) error { + k, err := t.resolveKegForRoles(ctx, opts.KegTargetOptions, FlightRoleAdmin, FlightRoleAdmin) if err != nil { return err } - var ( - configPath string - originalRaw []byte - ) - if k.Target() != nil && k.Target().Scheme() == keg.SchemeFile { - path, raw, readErr := readRawKegConfigWithPath(t.Runtime, k.Target().Path()) - if readErr != nil { - return fmt.Errorf("unable to read keg config: %w", readErr) - } - configPath = path - originalRaw = raw - } else { - cfg, cfgErr := k.Config(ctx) - if cfgErr != nil { - return fmt.Errorf("unable to read keg config: %w", cfgErr) - } - originalRaw = []byte(cfg.String()) + cfg, err := k.Settings(ctx) + if err != nil { + return fmt.Errorf("unable to read keg settings: %w", err) + } + originalRaw := cfg.Raw() + expectedHash := opts.ExpectedHash + if (opts.Stream == nil || !opts.Stream.IsPiped) && expectedHash == "" { + expectedHash = cfg.Hash() } + // The schema modeline is an editor affordance, not part of the document: + // it names a path that only resolves on the machine that opened the + // editor, and keg settings are persisted — on a hub, shared. So strip it + // on the way in, no matter which surface supplied the bytes (editor, + // piped stdin, or the keg_settings_edit MCP tool). saveConfig := func(data []byte) error { - if configPath != "" { - resolvedPath, err := t.Runtime.ResolvePath(configPath, true) - if err != nil { - return fmt.Errorf("unable to resolve keg config path: %w", err) - } - if err := t.Runtime.AtomicWriteFile(resolvedPath, data, 0o644); err != nil { - return fmt.Errorf("unable to save edited keg config: %w", err) - } - return nil - } - if err := k.SetConfig(ctx, data); err != nil { - return fmt.Errorf("unable to save edited keg config: %w", err) + data = schemas.StripModeline(data) + if err := k.SetSettings(ctx, data, keg.SettingsWriteOptions{ExpectedHash: expectedHash}); err != nil { + return fmt.Errorf("unable to save edited keg settings: %w", err) } + expectedHash = keg.DocumentHash(data) return nil } - initialRaw := originalRaw if opts.Stream != nil && opts.Stream.IsPiped { pipedRaw, readErr := io.ReadAll(opts.Stream.In) if readErr != nil { return fmt.Errorf("unable to read piped input: %w", readErr) } if len(bytes.TrimSpace(pipedRaw)) > 0 { - if bytes.Equal(pipedRaw, originalRaw) { + // Compare with the modeline stripped: piping back exactly what an + // editor was shown is a no-op, not an edit. + if bytes.Equal(schemas.StripModeline(pipedRaw), originalRaw) { return nil } - if _, parseErr := keg.ParseKegConfigStrict(pipedRaw); parseErr != nil { - return fmt.Errorf("keg config from stdin is invalid: %w", parseErr) + if _, parseErr := keg.ParseKegSettingsStrict(pipedRaw); parseErr != nil { + return fmt.Errorf("keg settings from stdin is invalid: %w", parseErr) } return saveConfig(pipedRaw) } @@ -619,6 +480,12 @@ func (t *Tap) KegConfigEdit(ctx context.Context, opts KegConfigEditOptions) erro if err != nil { return fmt.Errorf("unable to create temp file path: %w", err) } + // Add the modeline here and nowhere else: it exists so a language server + // can drive completion and validation in this buffer, and saveConfig + // strips it again before anything is persisted. Replace rather than + // prepend so a config written by an older build gets its stale line + // pointed at this build's schema. + initialRaw := schemas.ReplaceModeline(originalRaw, schemas.Modeline(t.Runtime, schemas.KegSettings)) if err := t.Runtime.WriteFile(tempPath, initialRaw, 0o600); err != nil { return fmt.Errorf("unable to write temp config file: %w", err) } @@ -627,12 +494,12 @@ func (t *Tap) KegConfigEdit(ctx context.Context, opts KegConfigEditOptions) erro }() if err := editWithLiveSaves(ctx, t.Runtime, tempPath, nil, func(editedRaw []byte) error { - if _, err := keg.ParseKegConfigStrict(editedRaw); err != nil { - return fmt.Errorf("keg config is invalid after editing: %w", err) + if _, err := keg.ParseKegSettingsStrict(editedRaw); err != nil { + return fmt.Errorf("keg settings is invalid after editing: %w", err) } return saveConfig(editedRaw) }); err != nil { - return fmt.Errorf("unable to edit keg config: %w", err) + return fmt.Errorf("unable to edit keg settings: %w", err) } return nil } diff --git a/pkg/tapper/tap_init.go b/pkg/tapper/tap_init.go index 46fd7c89..b8ab6f56 100644 --- a/pkg/tapper/tap_init.go +++ b/pkg/tapper/tap_init.go @@ -4,108 +4,37 @@ import ( "context" "errors" "fmt" - "path/filepath" "strings" - appCtx "github.com/jlrickert/cli-toolkit/appctx" - "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" ) +// InitOptions configures creation of a KEG on a configured remote hub. type InitOptions struct { - // Local (filesystem) project destination selectors. Any of these forces a - // project-local keg under the git root / cwd / explicit path; namespace and - // hub resolution do not apply. - Project bool - User bool // pin the reserved @local namespace (this machine's local hub) - Cwd bool // use cwd as the project root base instead of git root - Path string // explicit filesystem path; implies a local project destination - - // Destination overrides for the namespace-centric resolution. An empty - // Namespace defers to config (kegs[name].Namespace → default/fallback); an - // empty Hub defers to namespaces[ns].Hub → default/fallback. A bare name - // thus resolves to the default namespace+hub — typically a remote create — - // while "@local/" pins the filesystem hub. - Namespace string - Hub string - - TokenEnv string - - Creator string - Title string - Keg string - - // NonInteractive suppresses interactive prompts when set, forcing the - // caller to rely on flag-driven defaults (platform user-data dir, alias - // inferred from cwd, etc.) even when the surface is attached to a TTY. - // The Tap method itself does not consult this field — TTY handling is a - // CLI/MCP concern — but the option lives on InitOptions so both the CLI - // flag and the MCP input field map to the same canonical contract. - NonInteractive bool + Namespace string + Hub string + Title string + Keg string + Visibility string - // RequireBootstrap makes a namespace/hub create (anything but an explicit - // local destination) fail with ErrNotBootstrapped when no user config exists - // — `tap bootstrap` has not been run. Set by the full `tap` surface and the - // MCP server; left false for direct Tap API callers (e.g. tests), which keep - // the unconfigured local fallback. + // RequireBootstrap rejects config-driven creation until user setup exists. RequireBootstrap bool } -// LocalDestination reports whether the options force a project-local -// filesystem keg (as opposed to the namespace-resolved user/hub destination). -func (o InitOptions) LocalDestination() bool { - return o.Project || o.Cwd || strings.TrimSpace(o.Path) != "" -} - -// CreateKegOptions is the agent-facing keg creation request. It is deliberately -// narrower than InitOptions: the machine-local destination selectors (Project, -// Cwd, Path, User, Hub) describe a filesystem this MCP caller may not share -// with the server, so the agent surface names a namespace and nothing else. +// CreateKegOptions is the agent-facing KEG creation request. type CreateKegOptions struct { - // Namespace is the target namespace without the @ sigil. Empty resolves to - // the transport's default namespace. - Namespace string - // Keg is the alias to create. - Keg string - // Title is the human-readable keg title. Optional. - Title string - // Visibility is "private" or "public". Empty means the backend's default, - // which is private everywhere. Local filesystem kegs ignore it. + Namespace string + Keg string + Title string Visibility string } -// InitKeg creates a keg named options.Keg and initializes it at the resolved -// destination. Destination resolution is namespace-centric: -// -// - --project/--cwd/--path → a project-local filesystem keg under the git -// root (or cwd / explicit path). -// - otherwise the name resolves through namespace → hub (with --namespace and -// --hub as overrides, and --user pinning @local): a local hub yields a -// filesystem keg at /@/; a remote hub creates -// the keg on the hub (POST /api/v1/@/kegs), failing if it -// already exists. On success the keg is recorded in user config. +// InitKeg creates a KEG through the configured hub creation endpoint. func (t *Tap) InitKeg(ctx context.Context, options InitOptions) (*keg.Target, error) { name := strings.TrimSpace(options.Keg) if err := ValidateKegAlias(name); err != nil { return nil, err } - options.Keg = name - - // Explicit project-local destination: pure filesystem, no namespace/hub. - if options.LocalDestination() { - if options.User { - return nil, fmt.Errorf("--user cannot be combined with a local destination (--project/--cwd/--path)") - } - if strings.TrimSpace(options.Hub) != "" { - return nil, fmt.Errorf("--hub cannot be combined with a local destination (--project/--cwd/--path)") - } - return t.initProjectDestination(ctx, options) - } - - // A namespace/hub create needs configured hubs. On the full `tap` surface - // (and MCP) refuse rather than silently materializing a keg in a hidden - // platform dir when `tap bootstrap` has not been run. Explicit local - // destinations returned above; they remain available without setup. if options.RequireBootstrap && !t.ConfigService.UserConfigExists() { return nil, ErrNotBootstrapped } @@ -114,113 +43,37 @@ func (t *Tap) InitKeg(ctx context.Context, options InitOptions) (*keg.Target, er if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } - - // Resolve the namespace then the hosting hub, mirroring Config.ResolveRef's - // precedence so we can both create and record the keg. --user pins @local. - namespace := strings.TrimSpace(options.Namespace) - if namespace == "" && options.User { - namespace = LocalHubName - } - hubName := strings.TrimSpace(options.Hub) - namespace, hubName, entry, err := cfg.resolveNamespaceHub(namespace, hubName) + namespace, hubName, entry, err := cfg.resolveNamespaceHub(options.Namespace, options.Hub) if err != nil { - if strings.TrimSpace(options.Namespace) == "" && strings.TrimSpace(options.Hub) == "" && !options.User && - !options.RequireBootstrap && !t.ConfigService.UserConfigExists() { - namespace = LocalHubName - hubName = cfg.localHubName() - var ok bool - entry, ok = cfg.Hub(hubName) - if !ok { - return nil, fmt.Errorf("local hub %q is not configured", hubName) - } - } else { - return nil, fmt.Errorf("cannot init %q: %w", name, err) - } + return nil, fmt.Errorf("cannot create %q: %w", name, err) } kind := strings.TrimSpace(entry.Kind) if kind == "" { kind = HubKindRemote } - + if kind != HubKindRemote { + return nil, fmt.Errorf("hub %q kind %q does not support KEG creation: %w", hubName, kind, keg.ErrNotSupported) + } target, err := cfg.ResolveRef(t.Runtime, KegRef{Hub: hubName, Namespace: namespace, Name: name}) if err != nil { - return nil, fmt.Errorf("resolve init destination: %w", err) - } - - if kind == HubKindLocal { - return t.initLocalKeg(ctx, options, target, namespace, name) + return nil, fmt.Errorf("resolve create destination: %w", err) } return t.initRemoteKeg(ctx, options, target, hubName, namespace, name) } -// initProjectDestination creates a project-local filesystem keg under the git -// root (or cwd / explicit --path) at /kegs/. -func (t *Tap) initProjectDestination(ctx context.Context, options InitOptions) (*keg.Target, error) { - projectPath := strings.TrimSpace(options.Path) - if projectPath == "" { - base, err := t.Runtime.Getwd() - if err != nil { - return nil, fmt.Errorf("unable to determine working directory: %w", err) - } - if !options.Cwd { - if gitRoot := appCtx.FindGitRoot(ctx, t.Runtime, base); gitRoot != "" { - base = gitRoot - } - } - projectPath = filepath.Join(base, "kegs", options.Keg) - } - resolved, err := t.Runtime.ResolvePath(projectPath, false) - if err != nil { - return nil, fmt.Errorf("unable to resolve project path %q: %w", projectPath, err) - } - return t.initProjectKeg(ctx, initLocalOptions{ - Path: resolved, - Title: options.Title, - Creator: options.Creator, - }) -} - -// initLocalKeg materializes a filesystem-backed keg at the resolved local -// target and records it in user config under (name → namespace). -func (t *Tap) initLocalKeg(ctx context.Context, options InitOptions, target *keg.Target, namespace, name string) (*keg.Target, error) { - k, err := keg.NewKegFromTarget(ctx, *target, t.Runtime) - if err != nil { - return nil, fmt.Errorf("unable to init keg: %w", err) - } - if err := k.Init(ctx); err != nil { - return nil, err - } - if err := keg.UpdateConfig(ctx, k, func(kc *keg.Config) { - kc.Creator = options.Creator - kc.Title = options.Title - }); err != nil { - return nil, err - } - // Local namespaces resolve their hub via localHubName() and a local keg name - // resolves through the namespace-centric chain, so there is nothing to - // record for a local keg. - if err := t.recordInitKeg("", namespace); err != nil { - return nil, err - } - return k.Target(), nil -} - -// initRemoteKeg creates the keg on the hub (POST /api/v1/@/kegs), -// surfacing a 409 as an "already exists" error, then records the keg in user -// config (name → namespace and namespace → hub). func (t *Tap) initRemoteKeg(ctx context.Context, options InitOptions, target *keg.Target, hubName, namespace, name string) (*keg.Target, error) { hubURL := strings.TrimSpace(target.HubURL) if hubURL == "" { hubURL = strings.TrimSpace(target.Url) } if hubURL == "" { - return nil, fmt.Errorf("remote init requires a hub url; none resolved for hub %q", hubName) + return nil, fmt.Errorf("remote create requires a hub URL; none resolved for hub %q", hubName) } token := t.hubTokenForTarget(target) if token == "" { return nil, fmt.Errorf("not logged in to hub %q (run `tap auth login --hub %s`)", hubName, hubURL) } - if err := CreateKeg(ctx, hubURL, token, namespace, name, options.Title, ""); err != nil { + if err := CreateKeg(ctx, hubURL, token, namespace, name, options.Title, options.Visibility); err != nil { return nil, err } if err := t.recordInitKeg(hubName, namespace); err != nil { @@ -229,72 +82,23 @@ func (t *Tap) initRemoteKeg(ctx context.Context, options InitOptions, target *ke return target, nil } -// recordInitKeg persists routing for a freshly-created keg so future -// references resolve it. With the kegs alias table removed, a keg name resolves -// through the namespace-centric chain, so the only thing worth recording is the -// namespace→hub mapping for a remote keg (namespaces[namespace] pins the -// hosting hub). A local keg — or one with no namespace/hub to pin — needs -// nothing recorded and this is a no-op. func (t *Tap) recordInitKeg(hubName, namespace string) error { if strings.TrimSpace(namespace) == "" || strings.TrimSpace(hubName) == "" { return nil } - userCfg, err := t.ConfigService.ReadUserConfigFile() + userConfig, err := t.ConfigService.ReadUserConfigFile() if err != nil { if !errors.Is(err, keg.ErrNotExist) { return err } - userCfg = &Config{data: &configDTO{}} + userConfig = &Config{data: &configDTO{}} } - if err := userCfg.SetNamespace(namespace, NamespaceRef{Hub: hubName}); err != nil { + if err := userConfig.SetNamespace(namespace, NamespaceRef{Hub: hubName}); err != nil { return err } - if err := userCfg.Write(t.Runtime, t.PathService.UserConfig()); err != nil { + if err := userConfig.Write(t.Runtime, t.PathService.UserConfig()); err != nil { return err } - // The snapshot predates this write; drop it so nothing in this process - // reads back a value we just replaced. t.ConfigService.Reload() return nil } - -type initLocalOptions struct { - Path string - - Creator string - Title string -} - -// initProjectKeg creates a filesystem-backed keg repository at path. -// -// The destination directory is created and initialized via keg.Init, then -// creator/title metadata is applied to the generated keg config. -func (t *Tap) initProjectKeg(ctx context.Context, opts initLocalOptions) (*keg.Target, error) { - target := keg.NewFile(opts.Path) - k, err := keg.NewKegFromTarget(ctx, target, t.Runtime) - if err != nil { - return nil, fmt.Errorf("unable to init keg: %w", err) - } - err = k.Init(ctx) - if err != nil { - return nil, err - } - err = keg.UpdateConfig(ctx, k, func(kc *keg.Config) { - kc.Creator = opts.Creator - kc.Title = opts.Title - }) - return k.Target(), err -} - -// defaultUserKegRoot returns the platform-default directory under which user -// kegs are created when the local hub has no basePath configured. Linux/macOS -// resolve to /tapper/kegs; Windows resolves to -// %LOCALAPPDATA%\data\tapper\kegs. Resolution flows through the cli-toolkit -// runtime so sandboxed tests get the same answer as production. -func defaultUserKegRoot(rt *toolkit.Runtime) (string, error) { - dataDir, err := toolkit.UserDataPath(rt) - if err != nil { - return "", fmt.Errorf("resolve user data dir: %w", err) - } - return filepath.Join(dataDir, "tapper", "kegs"), nil -} diff --git a/pkg/tapper/tap_init_test.go b/pkg/tapper/tap_init_test.go index 1bd14862..804ad489 100644 --- a/pkg/tapper/tap_init_test.go +++ b/pkg/tapper/tap_init_test.go @@ -127,23 +127,3 @@ func TestInitKeg_RemoteCreate_Conflict(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, keg.ErrExist, "an existing remote keg must fail with ErrExist (409)") } - -func TestInitKeg_LocalViaNamespace(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), - []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644)) - - // The reserved @local namespace pins this machine's filesystem hub. - target, err := tap.InitKeg(fx.Context(), tapper.InitOptions{Keg: "notes", Namespace: tapper.LocalHubName}) - require.NoError(t, err) - require.Equal(t, keg.SchemeFile, target.Scheme()) - require.Contains(t, target.String(), "@local/notes") - - // The zero node and keg config were written to disk. - require.Contains(t, string(fx.MustReadFile("/home/testuser/kegs/@local/notes/keg")), "$schema=") -} diff --git a/pkg/tapper/tap_keg.go b/pkg/tapper/tap_keg.go index e3bf87dd..bfc593d0 100644 --- a/pkg/tapper/tap_keg.go +++ b/pkg/tapper/tap_keg.go @@ -62,9 +62,8 @@ var kegGrantRoles = map[string]bool{"viewer": true, "editor": true, "admin": tru var kegVisibilities = map[string]bool{"public": true, "private": true} -// hubKegAliasPattern mirrors tapper-hub's catalog alias regex. It is stricter -// than local keg aliases: hub aliases are lowercase alphanumeric plus hyphen, -// 1-64 chars, and must start with an alphanumeric. +// hubKegAliasPattern mirrors tapper-hub's catalog alias regex: lowercase +// alphanumeric plus hyphen, 1-64 chars, starting with an alphanumeric. var hubKegAliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) // KegGrants lists the per-(user, role) grants on a keg. @@ -168,7 +167,7 @@ func (t *Tap) resolveKegAdminRef(keg, nsOverride, hubOverride string) (namespace if oErr != nil { return "", "", "", "", oErr } - if ref.Path != "" || ref.Name == "" { + if ref.Name == "" { return "", "", "", "", fmt.Errorf("keg administration requires a hub-backed keg reference like @namespace/keg") } // Infer namespace + hub through the shared chain (same as ResolveRef), so a @@ -177,9 +176,6 @@ func (t *Tap) resolveKegAdminRef(keg, nsOverride, hubOverride string) (namespace if rErr != nil { return "", "", "", "", fmt.Errorf("keg %q: %w", raw, rErr) } - if strings.TrimSpace(entry.Kind) == HubKindLocal { - return "", "", "", "", fmt.Errorf("keg administration requires a remote hub-backed namespace") - } url := strings.TrimSpace(entry.URL) if url == "" { return "", "", "", "", fmt.Errorf("hub %q has no url configured", hubName) diff --git a/pkg/tapper/tap_keg_config_edit_test.go b/pkg/tapper/tap_keg_config_edit_test.go deleted file mode 100644 index dd29417c..00000000 --- a/pkg/tapper/tap_keg_config_edit_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package tapper - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/stretchr/testify/require" -) - -type configWriteCountingRepo struct { - keg.Repository - writes int -} - -func (r *configWriteCountingRepo) WriteConfig(ctx context.Context, cfg *keg.Config) error { - r.writes++ - return r.Repository.WriteConfig(ctx, cfg) -} - -func TestKegConfigEdit_SeparatesFlightAndIdentityRoles(t *testing.T) { - t.Parallel() - identityDenied := errors.New("identity lacks editor access") - tests := []struct { - name string - flight *Flight - identityAllowed bool - wantErr string - }{ - { - name: "admin cover with editor identity", - flight: &Flight{Name: "@foldwise/+admin", FlightManifest: FlightManifest{Cover: []FlightCover{{ - Namespace: "foldwise", Keg: "dev", Role: FlightRoleAdmin, - }}}}, - identityAllowed: true, - }, - { - name: "full access with editor identity", - flight: &Flight{Name: "@foldwise/+full", FlightManifest: FlightManifest{ - Capabilities: []FlightCapability{FlightCapabilityFullAccess}, - }}, - identityAllowed: true, - }, - { - name: "editor cover blocks admin operation", - flight: &Flight{Name: "@foldwise/+editor", FlightManifest: FlightManifest{Cover: []FlightCover{{ - Namespace: "foldwise", Keg: "dev", Role: FlightRoleEditor, - }}}}, - identityAllowed: true, - wantErr: "requires admin flight authority", - }, - { - name: "viewer cover blocks admin operation", - flight: &Flight{Name: "@foldwise/+viewer", FlightManifest: FlightManifest{Cover: []FlightCover{{ - Namespace: "foldwise", Keg: "dev", Role: FlightRoleViewer, - }}}}, - identityAllowed: true, - wantErr: "requires admin flight authority", - }, - { - name: "uncovered keg is blocked", - flight: &Flight{Name: "@foldwise/+empty"}, - identityAllowed: true, - wantErr: "is not available in flight", - }, - { - name: "admin cover cannot overcome viewer identity", - flight: &Flight{Name: "@foldwise/+admin", FlightManifest: FlightManifest{Cover: []FlightCover{{ - Namespace: "foldwise", Keg: "dev", Role: FlightRoleAdmin, - }}}}, - wantErr: identityDenied.Error(), - }, - { - name: "full access cannot overcome no identity access", - flight: &Flight{Name: "@foldwise/+full", FlightManifest: FlightManifest{ - Capabilities: []FlightCapability{FlightCapabilityFullAccess}, - }}, - wantErr: identityDenied.Error(), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - repo := &configWriteCountingRepo{Repository: keg.NewMemoryRepo(sb.Runtime())} - k := keg.NewLocalKeg(repo, sb.Runtime()) - require.NoError(t, k.Init(t.Context())) - k.SetTarget(&keg.Target{Namespace: "foldwise", KegName: "dev"}) - repo.writes = 0 - - var identityRole FlightRole - tap, err := NewTap(TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()}) - require.NoError(t, err) - tap.KegResolver = func(_ context.Context, _ KegTargetOptions, role FlightRole) (keg.Keg, error) { - identityRole = role - if !tc.identityAllowed { - return nil, identityDenied - } - return k, nil - } - err = tap.KegConfigEdit(t.Context(), KegConfigEditOptions{ - KegTargetOptions: KegTargetOptions{ - Keg: "@foldwise/dev", - FlightContext: tc.flight, - }, - Stream: &toolkit.Stream{ - IsPiped: true, - In: strings.NewReader(`kegv: 2025-07 -title: Edited by agent -`), - }, - }) - require.Equal(t, FlightRoleEditor, identityRole, "identity authorization must remain editor") - if tc.wantErr != "" { - require.ErrorContains(t, err, tc.wantErr) - require.Zero(t, repo.writes) - return - } - require.NoError(t, err) - require.Equal(t, 1, repo.writes) - cfg, readErr := k.Config(t.Context()) - require.NoError(t, readErr) - require.Equal(t, "Edited by agent", cfg.Title) - }) - } -} - -func TestKegConfigEdit_InvalidAndUnchangedInputDoNotWrite(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - repo := &configWriteCountingRepo{Repository: keg.NewMemoryRepo(sb.Runtime())} - k := keg.NewLocalKeg(repo, sb.Runtime()) - require.NoError(t, k.Init(t.Context())) - k.SetTarget(&keg.Target{Namespace: "foldwise", KegName: "dev"}) - tap, err := NewTap(TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()}) - require.NoError(t, err) - tap.KegResolver = func(context.Context, KegTargetOptions, FlightRole) (keg.Keg, error) { - return k, nil - } - flight := &Flight{Name: "@foldwise/+admin", FlightManifest: FlightManifest{Cover: []FlightCover{{ - Namespace: "foldwise", Keg: "dev", Role: FlightRoleAdmin, - }}}} - - repo.writes = 0 - err = tap.KegConfigEdit(t.Context(), KegConfigEditOptions{ - KegTargetOptions: KegTargetOptions{FlightContext: flight}, - Stream: &toolkit.Stream{IsPiped: true, In: strings.NewReader("kegv: [\n")}, - }) - require.ErrorContains(t, err, "keg config from stdin is invalid") - require.Zero(t, repo.writes) - - cfg, err := k.Config(t.Context()) - require.NoError(t, err) - repo.writes = 0 - err = tap.KegConfigEdit(t.Context(), KegConfigEditOptions{ - KegTargetOptions: KegTargetOptions{FlightContext: flight}, - Stream: &toolkit.Stream{IsPiped: true, In: strings.NewReader(cfg.String())}, - }) - require.NoError(t, err) - require.Zero(t, repo.writes, "unchanged config must be a no-op") -} diff --git a/pkg/tapper/tap_keg_settings_batch_test.go b/pkg/tapper/tap_keg_settings_batch_test.go index 54065657..b8008209 100644 --- a/pkg/tapper/tap_keg_settings_batch_test.go +++ b/pkg/tapper/tap_keg_settings_batch_test.go @@ -1,7 +1,6 @@ package tapper_test import ( - "context" "encoding/json" "fmt" "net/http" @@ -15,28 +14,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestKegSettingsBatch_GroupsByHubAndPreservesInputOrder(t *testing.T) { +func TestKegSettingsBatch_UsesOrdinarySettingsAndPreservesInputOrder(t *testing.T) { t.Parallel() - type detailRequest struct { - Kegs []string `json:"kegs"` - } var aCalls, bCalls atomic.Int32 newHub := func(calls *atomic.Int32, prefix string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - require.Equal(t, "/api/v1/orient/details", r.URL.Path) - var request detailRequest - require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - out := make([]tapper.HubOrientationDetail, 0, len(request.Kegs)) - for _, ref := range request.Kegs { - out = append(out, tapper.HubOrientationDetail{ - Keg: ref, - Title: prefix + " " + ref, - Summary: "summary " + ref, - Instructions: "instructions " + ref, - }) - } - _ = json.NewEncoder(w).Encode(out) + require.Equal(t, http.MethodGet, r.Method) + require.True(t, strings.HasSuffix(r.URL.Path, "/settings"), r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]string{ + "kegv": "2025-07", "title": prefix + " " + r.URL.Path, + "summary": "summary " + r.URL.Path, "instructions": "instructions " + r.URL.Path, + }) })) } hubA := newHub(&aCalls, "A") @@ -62,7 +51,7 @@ namespaces: Minimal: true, }) require.NoError(t, err) - require.EqualValues(t, 1, aCalls.Load()) + require.EqualValues(t, 2, aCalls.Load()) require.EqualValues(t, 1, bCalls.Load()) first := strings.Index(out, "keg: '@team-a/one'") second := strings.Index(out, "keg: '@team-b/two'") @@ -72,21 +61,18 @@ namespaces: require.Greater(t, third, second) } -func TestKegSettingsBatch_OlderHubFallsBackToPerKegConfig(t *testing.T) { +func TestKegSettingsBatch_NeverCallsRemovedOrientationRoute(t *testing.T) { t.Parallel() - var batchCalls, configCalls atomic.Int32 + var settingsCalls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v1/orient/details": - batchCalls.Add(1) - http.NotFound(w, r) - case "/api/v1/@legacy/kegs/one/config": - configCalls.Add(1) + case "/api/v1/@legacy/kegs/one/settings": + settingsCalls.Add(1) _ = json.NewEncoder(w).Encode(map[string]any{ "kegv": "2025-07", "title": "One", "instructions": "One guidance", }) - case "/api/v1/@legacy/kegs/two/config": - configCalls.Add(1) + case "/api/v1/@legacy/kegs/two/settings": + settingsCalls.Add(1) _ = json.NewEncoder(w).Encode(map[string]any{ "kegv": "2025-07", "title": "Two", "instructions": "Two guidance", }) @@ -111,32 +97,7 @@ namespaces: Minimal: true, }) require.NoError(t, err) - require.EqualValues(t, 1, batchCalls.Load()) - require.EqualValues(t, 2, configCalls.Load()) + require.EqualValues(t, 2, settingsCalls.Load()) require.Contains(t, out, "One guidance") require.Contains(t, out, "Two guidance") } - -func TestKegSettingsBatch_HostedResolverCalledOnce(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) - require.NoError(t, err) - var calls atomic.Int32 - tap.OrientationDetailsResolver = func(_ context.Context, refs []string) ([]tapper.HubOrientationDetail, error) { - calls.Add(1) - out := make([]tapper.HubOrientationDetail, len(refs)) - for i, ref := range refs { - out[i] = tapper.HubOrientationDetail{Keg: ref, Title: ref} - } - return out, nil - } - refs := []string{"@foldwise/dev", "@foldwise/engineering"} - out, err := tap.KegSettings(sb.Context(), tapper.KegSettingsOptions{ - Kegs: refs, - Minimal: true, - }) - require.NoError(t, err) - require.EqualValues(t, 1, calls.Load()) - require.Less(t, strings.Index(out, refs[0]), strings.Index(out, refs[1])) -} diff --git a/pkg/tapper/tap_keg_test.go b/pkg/tapper/tap_keg_test.go index c4c9153a..15d5ad21 100644 --- a/pkg/tapper/tap_keg_test.go +++ b/pkg/tapper/tap_keg_test.go @@ -92,7 +92,7 @@ func TestKegVisibility(t *testing.T) { var gotBody map[string]string h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPatch, r.Method) - require.Equal(t, "/api/v1/@jlrickert/kegs/example/settings", r.URL.Path) + require.Equal(t, "/api/v1/@jlrickert/kegs/example/access", r.URL.Path) body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &gotBody) _ = json.NewEncoder(w).Encode(map[string]string{"visibility": "public"}) @@ -117,8 +117,8 @@ func TestKegRename_QualifiedOld(t *testing.T) { t.Parallel() var gotBody map[string]string h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPatch, r.Method) - require.Equal(t, "/api/v1/@jlrickert/kegs/example/settings", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/@jlrickert/kegs/example/rename", r.URL.Path) body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &gotBody) _ = json.NewEncoder(w).Encode(map[string]string{"namespace": "jlrickert", "alias": "renamed"}) @@ -131,7 +131,8 @@ func TestKegRename_QualifiedOld(t *testing.T) { func TestKegRename_BareOldWithNamespaceOverride(t *testing.T) { t.Parallel() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/api/v1/@jlrickert/kegs/example/settings", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/@jlrickert/kegs/example/rename", r.URL.Path) w.WriteHeader(http.StatusNoContent) }) tap, fx, _ := newRemoteHubTap(t, h) diff --git a/pkg/tapper/tap_launch.go b/pkg/tapper/tap_launch.go index 78e34a35..87e9adcb 100644 --- a/pkg/tapper/tap_launch.go +++ b/pkg/tapper/tap_launch.go @@ -54,12 +54,24 @@ var providerKeyEnv = map[string][]string{ ProviderOllama: {"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"}, } +// noLaunchFlightWarning is emitted when a launch resolves no root. The session +// is not unauthorized — it inherits exactly the identity's own access — but that +// is broader than a flight, so say which authority is in play and how to narrow +// it rather than letting the reader infer either. +const noLaunchFlightWarning = "no flight configured; this agent runs with " + + "identity-authorized full access to every KEG you can reach. Create a flight " + + "and set `flight:` in Tapper configuration to restrict it." + // LaunchOptions configures behavior for Tap.Launch. type LaunchOptions struct { // Harness names the agent CLI to start: claude, codex, or pi. Harness string - // Agent names an entry in the config's agents map. + // Agent names an entry in the config's agents map. Empty falls back to the + // config's agent key (which TAP_AGENT also feeds). Agent string + // Flight is the explicit launch root. Empty falls back through TAP_FLIGHT, + // project configuration, and user configuration. + Flight string // DryRun resolves and reports the invocation without executing it. DryRun bool // Args are extra arguments appended to the harness invocation. @@ -71,9 +83,12 @@ type LaunchOptions struct { // from it. Neither contains a secret value — a forwarded key is reported by the // variable it came from. // -// Flight is what the agent points at right now, reported for the operator's -// benefit. It is not what gets exported: the child resolves the flight itself -// from TAP_AGENT, so this value can go stale the moment the config changes. +// Flight is the canonical connection-pinned Hub-backed root exported to the +// child as TAP_FLIGHT. It is empty when no flight is configured; the harness +// then runs under no-flight identity authority and Warnings says so. +// +// Warnings are returned rather than printed so a dry run and a real run report +// the same thing — see ResolveLaunch. type LaunchResult struct { Harness string Agent string @@ -86,6 +101,7 @@ type LaunchResult struct { Env map[string]string StripEnv []string KeySource string + Warnings []string } // launchSpec is one resolved agent, handed to a harness builder. @@ -290,20 +306,59 @@ func (t *Tap) ResolveLaunch(opts LaunchOptions) (*LaunchResult, error) { opts.Harness, strings.Join(LaunchHarnesses(), ", ")) } - agentName := strings.TrimSpace(opts.Agent) - if agentName == "" { - return nil, fmt.Errorf("an agent is required: pass --agent") - } cfg, err := t.ConfigService.Config() if err != nil { return nil, err } + // --agent wins; otherwise fall back to the config's agent key, which + // TAP_AGENT also feeds. This mirrors ActiveFlightName's explicit-then-config + // resolution used for the launch root just below. Reading the fallback off + // the same cfg snapshot as the lookup keeps the two from drifting. + agentName := strings.TrimSpace(opts.Agent) + if agentName == "" { + agentName = cfg.AgentName() + } + if agentName == "" { + return nil, fmt.Errorf( + "an agent is required (pass --agent, or set agent in Tapper configuration or TAP_AGENT)") + } agent, ok := cfg.Agent(agentName) if !ok { return nil, fmt.Errorf("unknown agent %q (configured: %s)", agentName, strings.Join(configuredAgentNames(cfg), ", ")) } - + // A launch root is optional. Without one the child runs under no-flight + // identity authority — the same state bare `tap mcp` and hosted /mcp reach — + // which is what makes bootstrapping possible: you cannot be required to + // select a flight in order to launch the session that creates your first one. + // There is nothing to validate in that case, and no namespace to resolve a + // hub from; the child discovers across every configured hub itself. + var ( + root FlightRef + hasRoot bool + warnings []string + ) + if rootRef := strings.TrimSpace(t.ActiveFlightName(opts.Flight)); rootRef != "" { + parsed, err := ParseFlightRef(rootRef, defaultFlightNamespace(cfg)) + if err != nil { + return nil, fmt.Errorf("resolve launch flight %q: %w", rootRef, err) + } + if parsed.Namespace == "" { + return nil, fmt.Errorf("tap launch requires a canonical Hub-backed root flight; %q has no namespace", rootRef) + } + hubName := cfg.resolveHubForNamespace(parsed.Namespace) + hub, ok := cfg.Hub(hubName) + if !ok { + return nil, fmt.Errorf("resolve launch flight %q: hub %q is not configured", rootRef, hubName) + } + kind := hubKindOrDefault(hub.Kind) + if kind != HubKindRemote && kind != HubKindReadonly { + return nil, fmt.Errorf("resolve launch flight %q: hub %q has unsupported kind %q", rootRef, hubName, kind) + } + root, hasRoot = parsed, true + } else { + warnings = append(warnings, noLaunchFlightWarning) + } provider, model, err := ParseAgentModel(agent.Model) if err != nil { return nil, fmt.Errorf("agent %q: %w", agentName, err) @@ -353,13 +408,16 @@ func (t *Tap) ResolveLaunch(opts LaunchOptions) (*LaunchResult, error) { if env == nil { env = map[string]string{} } - // Export the agent, not the flight it currently resolves to. The launched - // process looks up agents[TAP_AGENT].flight on every config load, so editing - // the agent's flight and re-orienting moves a running session. Exporting - // TAP_FLIGHT here instead would pin the value into an environment that - // cannot be changed after exec, leaving the session stuck on whatever the - // flight was at launch no matter what the config later said. + // TAP_AGENT is model selection and telemetry only. TAP_FLIGHT pins the + // canonical launch root for the child process lifetime; governed calls may + // select a live accessible descendant but never replace that root. It is + // left unset when no flight is configured, which is exactly how the child's + // `tap mcp` decides it is not launcher-bound and resolves identity authority + // instead (see cmd_mcp.go). env["TAP_AGENT"] = agentName + if hasRoot { + env["TAP_FLIGHT"] = root.Canonical() + } // Subscription mode has to remove inherited credentials, which an overlay // cannot express: appending can override a variable but never unset one. @@ -373,18 +431,23 @@ func (t *Tap) ResolveLaunch(opts LaunchOptions) (*LaunchResult, error) { sort.Strings(strip) } + flight := "" + if hasRoot { + flight = root.Canonical() + } return &LaunchResult{ Harness: harness, Agent: agentName, Provider: provider, Model: model, BaseURL: baseURL, - Flight: strings.TrimSpace(agent.Flight), + Flight: flight, Auth: auth, Argv: argv, Env: env, StripEnv: strip, KeySource: keySource, + Warnings: warnings, }, nil } @@ -449,11 +512,22 @@ func (t *Tap) resolveAPIKey(agent AgentEntry, auth string) (key, source string, // Launch resolves the agent and starts the harness, wiring it to the runtime's // streams so it runs interactively. With DryRun set it resolves and returns // without executing. +// +// Warnings are written to stderr here, before the harness takes the terminal. +// Reporting them from the returned result would be too late: Launch does not +// return until the agent session has ended, so the reader would learn what +// authority the session had only after it was over. Stderr also keeps them clear +// of a piped dry-run report. func (t *Tap) Launch(ctx context.Context, opts LaunchOptions) (*LaunchResult, error) { resolved, err := t.ResolveLaunch(opts) if err != nil { return nil, err } + if stream := t.Runtime.Stream(); stream != nil && stream.Err != nil { + for _, warning := range resolved.Warnings { + fmt.Fprintln(stream.Err, "warning: "+warning) + } + } if opts.DryRun { return resolved, nil } diff --git a/pkg/tapper/tap_launch_test.go b/pkg/tapper/tap_launch_test.go index 0b5a7c8f..5694ba8e 100644 --- a/pkg/tapper/tap_launch_test.go +++ b/pkg/tapper/tap_launch_test.go @@ -24,6 +24,12 @@ func newLaunchTap(t *testing.T, userConfig string) *tapper.Tap { } const launchUserConfig = `fallbackNamespace: local +flight: "@testuser/+root" +defaultHub: atlas +hubs: + atlas: + kind: remote + url: https://atlas.example.test agents: opus: model: anthropic/claude-opus-4 @@ -97,10 +103,9 @@ func TestResolveLaunch_AnthropicOnClaude(t *testing.T) { // Claude Code takes its model through the environment, not a flag. require.Equal(t, []string{"claude"}, got.Argv) require.Equal(t, "claude-opus-4", got.Env["ANTHROPIC_MODEL"]) - // The agent, not the flight it currently names: the child re-resolves the - // flight on every load so a config edit can move a running session. require.Equal(t, "opus", got.Env["TAP_AGENT"]) - require.NotContains(t, got.Env, "TAP_FLIGHT") + require.Equal(t, "@testuser/+root", got.Env["TAP_FLIGHT"]) + require.Equal(t, "@testuser/+root", got.Flight) } // Codex has first-class local-provider support and configures it through @@ -122,7 +127,7 @@ func TestResolveLaunch_OllamaOnCodexUsesOSSProvider(t *testing.T) { require.NotContains(t, got.Env, "OPENAI_BASE_URL") require.NotContains(t, got.Env, "OPENAI_API_KEY") require.Equal(t, "local", got.Env["TAP_AGENT"]) - require.NotContains(t, got.Env, "TAP_FLIGHT") + require.Equal(t, "@testuser/+root", got.Env["TAP_FLIGHT"]) } func TestResolveLaunch_OpenAIOnCodexLeavesDefaultEndpoint(t *testing.T) { @@ -134,10 +139,9 @@ func TestResolveLaunch_OpenAIOnCodexLeavesDefaultEndpoint(t *testing.T) { require.Equal(t, []string{"codex", "--model", "gpt-5"}, got.Argv) require.NotContains(t, got.Env, "OPENAI_BASE_URL") - // An agent may omit its flight. The agent is still exported — resolution - // simply finds no flight on it and falls through to project/user config. + // An agent may omit its legacy flight field; the root is independent. require.Equal(t, "hosted", got.Env["TAP_AGENT"]) - require.NotContains(t, got.Env, "TAP_FLIGHT") + require.Equal(t, "@testuser/+root", got.Env["TAP_FLIGHT"]) } // Ollama serves both /v1/messages and /v1/chat/completions, so it is the one @@ -272,6 +276,8 @@ func TestResolveLaunch_ErrorsOnUnknownInputs(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "unknown agent") + // launchUserConfig sets no top-level agent, so there is no default to fall + // back to and the omission is an error. _, err = tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude"}) require.Error(t, err) require.Contains(t, err.Error(), "an agent is required") @@ -281,6 +287,121 @@ func TestResolveLaunch_ErrorsOnUnknownInputs(t *testing.T) { require.Contains(t, err.Error(), "provider-qualified") } +// TestResolveLaunch_AgentDefaultsToConfig pins the fallback: --agent wins, and +// omitting it falls back to the top-level agent key, mirroring how flight +// supplies the launch root. +func TestResolveLaunch_AgentDefaultsToConfig(t *testing.T) { + t.Parallel() + tap := newLaunchTap(t, "agent: opus\n"+launchUserConfig) + + got, err := tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude"}) + require.NoError(t, err) + require.Equal(t, "opus", got.Agent) + + // An explicit --agent still overrides the configured default. + got, err = tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude", Agent: "local"}) + require.NoError(t, err) + require.Equal(t, "local", got.Agent) + + // A default naming an entry that does not exist fails like any other + // unknown agent rather than being silently ignored. + missing := newLaunchTap(t, "agent: ghost\n"+launchUserConfig) + _, err = missing.ResolveLaunch(tapper.LaunchOptions{Harness: "claude"}) + require.Error(t, err) + require.Contains(t, err.Error(), `unknown agent "ghost"`) +} + +// TestResolveLaunch_AgentDefaultsFromEnv covers the other feed into the same +// key: TAP_AGENT, which is what a launched process inherits. +func TestResolveLaunch_AgentDefaultsFromEnv(t *testing.T) { + t.Parallel() + sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) + require.NoError(t, sb.Runtime().AtomicWriteFile( + "/home/testuser/.config/tapper/config.yaml", []byte(launchUserConfig), 0o644)) + require.NoError(t, sb.Runtime().Set("TAP_AGENT", "local")) + + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) + require.NoError(t, err) + + got, err := tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude"}) + require.NoError(t, err) + require.Equal(t, "local", got.Agent) +} + +// A flight is optional. Requiring one made bootstrapping impossible: creating +// the first flight needs an agent session, and launching that session needed a +// flight. Without one the child gets no TAP_FLIGHT, which is precisely how its +// `tap mcp` decides it is not launcher-bound and resolves identity authority. +func TestResolveLaunch_LaunchesWithoutFlight(t *testing.T) { + t.Parallel() + + tap := newLaunchTap(t, `agents: + opus: {model: anthropic/claude-opus-4} +`) + got, err := tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude", Agent: "opus"}) + require.NoError(t, err) + require.Empty(t, got.Flight) + require.NotContains(t, got.Env, "TAP_FLIGHT", + "a no-flight launch must not pin a root, or the child reports itself launcher-bound") + require.Equal(t, "opus", got.Env["TAP_AGENT"]) + require.Len(t, got.Warnings, 1) + require.Contains(t, got.Warnings[0], "full access") +} + +func TestResolveLaunch_RequiresHubBackedRoot(t *testing.T) { + t.Parallel() + + local := newLaunchTap(t, `flight: "@local/+dev" +defaultHub: home +hubs: + home: {kind: local, basePath: /home/testuser/kegs, defaultNamespace: local} +agents: + opus: {model: anthropic/claude-opus-4} +`) + _, err := local.ResolveLaunch(tapper.LaunchOptions{Harness: "claude", Agent: "opus"}) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported kind \"local\"") +} + +// A configured root is still pinned immutably for the child's lifetime. +func TestResolveLaunch_PinsConfiguredRoot(t *testing.T) { + t.Parallel() + tap := newLaunchTap(t, launchUserConfig) + + got, err := tap.ResolveLaunch(tapper.LaunchOptions{Harness: "claude", Agent: "opus"}) + require.NoError(t, err) + require.NotEmpty(t, got.Flight) + require.Equal(t, got.Flight, got.Env["TAP_FLIGHT"]) + require.Empty(t, got.Warnings) +} + +func TestResolveLaunch_ExplicitFlightOverridesCascade(t *testing.T) { + t.Parallel() + sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) + require.NoError(t, sb.Setwd("/home/testuser/work/project")) + require.NoError(t, sb.Runtime().AtomicWriteFile( + "/home/testuser/.config/tapper/config.yaml", []byte(`flight: "@user/+root" +defaultHub: atlas +hubs: + atlas: {kind: remote, url: https://atlas.example.test} +agents: + opus: {model: anthropic/claude-opus-4} +`), 0o644)) + require.NoError(t, sb.Runtime().AtomicWriteFile( + "/home/testuser/work/project/.tapper/config.yaml", + []byte("flight: '@project/+root'\n"), 0o644)) + require.NoError(t, sb.Runtime().Env().Set("TAP_FLIGHT", "@environment/+root")) + + tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) + require.NoError(t, err) + got, err := tap.ResolveLaunch(tapper.LaunchOptions{ + Harness: "claude", Agent: "opus", Flight: "@explicit/+root", + }) + require.NoError(t, err) + require.Equal(t, "@explicit/+root", got.Flight) + require.Equal(t, "@explicit/+root", got.Env["TAP_FLIGHT"]) +} + func TestResolveLaunch_AppendsPassthroughArgs(t *testing.T) { t.Parallel() tap := newLaunchTap(t, launchUserConfig) @@ -297,12 +418,12 @@ func TestResolveLaunch_ReadsAgentsFromProjectConfig(t *testing.T) { sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) require.NoError(t, sb.Setwd("/home/testuser/work/project")) require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/.config/tapper/config.yaml", []byte("fallbackNamespace: local\n"), 0o644)) + "/home/testuser/.config/tapper/config.yaml", []byte("flight: '@testuser/+root'\ndefaultHub: atlas\nhubs:\n atlas: {kind: remote, url: https://atlas.example.test}\n"), 0o644)) // Agents carry no credentials, so unlike hubs they survive the project // config's trust boundary. require.NoError(t, sb.Runtime().AtomicWriteFile( "/home/testuser/work/project/.tapper/config.yaml", - []byte("agents:\n proj:\n model: openai/gpt-5\n flight: +proj\n"), 0o644)) + []byte("agents:\n proj:\n model: openai/gpt-5\n flight: +ignored\n"), 0o644)) tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()}) require.NoError(t, err) @@ -311,8 +432,8 @@ func TestResolveLaunch_ReadsAgentsFromProjectConfig(t *testing.T) { require.NoError(t, err) require.Equal(t, "gpt-5", got.Model) require.Equal(t, "proj", got.Env["TAP_AGENT"]) - // Still reported, so a dry run can show what the agent currently points at. - require.Equal(t, "+proj", got.Flight) + require.Equal(t, "@testuser/+root", got.Flight) + require.Equal(t, "@testuser/+root", got.Env["TAP_FLIGHT"]) } // A context cap means the same thing to a user on either harness but is spelled diff --git a/pkg/tapper/tap_list.go b/pkg/tapper/tap_list.go index ef73b516..2cff8626 100644 --- a/pkg/tapper/tap_list.go +++ b/pkg/tapper/tap_list.go @@ -226,7 +226,7 @@ func (t *Tap) resolveListFormat(ctx context.Context, k keg.Keg, explicit string) if strings.TrimSpace(explicit) != "" { return explicit } - cfg, err := k.Config(ctx) + cfg, err := k.Settings(ctx) if err != nil || cfg == nil || len(cfg.ListFields) == 0 { return explicit } @@ -234,7 +234,7 @@ func (t *Tap) resolveListFormat(ctx context.Context, k keg.Keg, explicit string) } // formatFromFieldSelectors renders a selector list as a tab-separated format -// string, so keg configuration and --format share one language. +// string, so keg settings and --format share one language. func formatFromFieldSelectors(fields []string) string { parts := make([]string, 0, len(fields)) for _, field := range fields { diff --git a/pkg/tapper/tap_list_test.go b/pkg/tapper/tap_list_test.go deleted file mode 100644 index fe10f7b4..00000000 --- a/pkg/tapper/tap_list_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package tapper_test - -import ( - "bytes" - "io" - "strings" - "testing" - - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// TestList_ReflectsExternalWrites is a regression test for the stale-dex -// bug on long-lived Tap consumers (the MCP server). It verifies that -// Tap.List observes nodes written by another process between calls. -// -// The bug: Tap.List used k.Dex(ctx), the cache-only fast path, so after -// an external writer added nodes the next List call on the MCP server -// returned the stale cached view. The fix flips Tap.List (and the other -// MCP read surfaces) to k.Dex(ctx), which checks the mtime of -// dex/nodes.tsv and reloads when the on-disk index has changed. -// -// The two Tap instances below share the same FsRepo root but each has -// its own KegService cache, so they materialise distinct *keg.Keg -// objects with independent dex caches — this faithfully simulates the -// cross-process scenario where the MCP server and a CLI writer both -// hold their own Keg instance. -func TestList_ReflectsExternalWrites(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - - // Build the shared keg on disk via a setup Tap — this writes both the - // tap user config and the keg config/zero node. - setup := setupTapWithKeg(t, fx) - _ = setup - - // Two independent Tap instances with their own KegService caches, - // each resolving to the same FsRepo root configured by the setup - // above (kegSearchPaths → /home/testuser/kegs/test). - newTap := func() *tapper.Tap { - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser/work", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - return tap - } - tapA := newTap() - tapB := newTap() - - // Tap A lists first, populating its cached dex with the initial - // (empty-apart-from-zero-node) state. - initial, err := tapA.List(fx.Context(), tapper.ListOptions{}) - require.NoError(t, err) - // Initial list may contain the zero node — the assertion below cares - // only about the three new nodes not the starting state. - _ = initial - - // Tap B creates three nodes. Because tapB has its own *Keg and - // therefore its own dex cache, this is structurally equivalent to a - // separate CLI process writing through FsRepo. - createVia := func(tp *tapper.Tap, title string) string { - content := "# " + title + "\n\nBody for " + title + ".\n" - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte(content))), - IsPiped: true, - } - id, err := tp.Create(fx.Context(), tapper.CreateOptions{ - Title: title, - Stream: stream, - }) - require.NoError(t, err) - return id.String() - } - idX := createVia(tapB, "X") - idY := createVia(tapB, "Y") - idZ := createVia(tapB, "Z") - - // Tap A lists again. With the pre-fix code (k.Dex cache-only), this - // second list returns the stale snapshot from before Tap B's writes - // and the three new nodes are missing. With the fix (k.DexFresh), - // Tap A detects the mtime change on dex/nodes.tsv and reloads. - out, err := tapA.List(fx.Context(), tapper.ListOptions{}) - require.NoError(t, err) - - // Build an id-only set from the list output. The default list - // format is tab-delimited with the node ID in the first column; - // splitting on the first tab gives an exact ID without matching - // substrings inside timestamps or titles. - seen := make(map[string]struct{}, len(out)) - for _, line := range out { - if idx := strings.IndexByte(line, '\t'); idx >= 0 { - seen[line[:idx]] = struct{}{} - } else { - seen[line] = struct{}{} - } - } - - for _, want := range []string{idX, idY, idZ} { - if _, ok := seen[want]; !ok { - t.Fatalf("Tap A second List should reflect node %q created by Tap B "+ - "(stale-dex regression); got output:\n%s", - want, strings.Join(out, "\n")) - } - } -} diff --git a/pkg/tapper/tap_move.go b/pkg/tapper/tap_move.go index 9a0fa870..5874ea9d 100644 --- a/pkg/tapper/tap_move.go +++ b/pkg/tapper/tap_move.go @@ -11,8 +11,27 @@ import ( type MoveOptions struct { KegTargetOptions - SourceID string - DestID string + SourceID string + DestID string + ExpectedHash string +} + +// NodeHash performs the read half of an explicit CLI read-before-write flow. +// Mutation methods never call it implicitly. +func (t *Tap) NodeHash(ctx context.Context, opts KegTargetOptions, rawID string) (string, error) { + k, err := t.resolveKegForRole(ctx, opts, FlightRoleViewer) + if err != nil { + return "", err + } + k, id, err := t.resolveNodeArg(ctx, k, rawID) + if err != nil { + return "", err + } + view, err := k.ReadNode(ctx, id) + if err != nil { + return "", err + } + return view.Hash(), nil } func (t *Tap) Move(ctx context.Context, opts MoveOptions) error { @@ -35,7 +54,7 @@ func (t *Tap) Move(ctx context.Context, opts MoveOptions) error { return err } - if _, err := k.Move(ctx, srcID, dstID); err != nil { + if _, err := k.Move(ctx, keg.NodeMoveOptions{Source: srcID, Destination: dstID, ExpectedHash: opts.ExpectedHash}); err != nil { if errors.Is(err, keg.ErrNotExist) { return fmt.Errorf("node %s not found in %s", srcID.Path(), describeKeg(k)) } diff --git a/pkg/tapper/tap_namespace.go b/pkg/tapper/tap_namespace.go index aae7ac90..bb4740c9 100644 --- a/pkg/tapper/tap_namespace.go +++ b/pkg/tapper/tap_namespace.go @@ -215,9 +215,6 @@ func (t *Tap) resolveHubUIEndpoint(hubOverride string) (hubName, hubURL string, if !ok { return "", "", fmt.Errorf("hub %q is not configured", hubName) } - if strings.TrimSpace(entry.Kind) == HubKindLocal { - return "", "", fmt.Errorf("namespace creation requires a remote hub UI") - } raw := strings.TrimSpace(entry.URL) if raw == "" { return "", "", fmt.Errorf("hub %q has no url configured", hubName) @@ -232,9 +229,6 @@ func (t *Tap) resolveHubUIEndpoint(hubOverride string) (hubName, hubURL string, // remoteHubEndpoint validates a hub entry as a usable remote endpoint and // returns its URL + resolved bearer token. func remoteHubEndpoint(t *Tap, hubName string, entry HubEntry) (hubURL, token string, err error) { - if strings.TrimSpace(entry.Kind) == HubKindLocal { - return "", "", fmt.Errorf("namespace administration requires a remote hub") - } url := strings.TrimSpace(entry.URL) if url == "" { return "", "", fmt.Errorf("hub %q has no url configured", hubName) diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go index 60df38c7..9339eae4 100644 --- a/pkg/tapper/tap_orient.go +++ b/pkg/tapper/tap_orient.go @@ -2,10 +2,8 @@ package tapper import ( "context" - "errors" "fmt" "io/fs" - "path/filepath" "sort" "strings" @@ -18,12 +16,11 @@ const orientPurpose = "Tapper provides an MCP interface for KEG (Knowledge Excha const orientRulesSummary = "Rules:\n" + "- Call `orient` first in every session, before any other tool and before replying. The active flight carries this session's instructions, so until you orient you do not know what the session is for.\n" + "- Call `orient` again after any context reset such as a clear or a compact. These instructions were delivered into the conversation and are discarded with it; the connection survives, so nothing re-sends them on its own. If you cannot tell whether you have oriented in the current context, you have not.\n" + - "- This payload supersedes every earlier copy of itself. An older copy can still be present — the connection's startup instructions are captured once and never refreshed, and a compaction summary may paraphrase a previous orientation — so if anything you remember about the flight, its cover, or its instructions disagrees with what you are reading here, this is current and that is stale. Do not merge them; replace.\n" + + "- These operating rules also ship once at initialization and do not change. The flight, its cover, and its instructions do change, and this payload is the only current source for them: a compaction summary may paraphrase a previous orientation, and an older copy can still be present. If anything you remember about the flight, its cover, or its instructions disagrees with what you are reading here, this is current and that is stale. Do not merge them; replace.\n" + "- Use the `mcp__tapper__*` tools for every KEG operation; never read or write node files directly.\n" + "- The target keg resolves from the working directory unless the `keg` parameter overrides it.\n" + "- Take a snapshot before non-trivial edits. Snapshots do not protect against `remove`; preserve content some other way before deletion.\n" + "- Node 0 is the keg's placeholder landing node. Leave it alone: it carries no `type` on purpose, it is where links to unwritten content land, and removing it makes the keg read as uninitialized. Write your content in a new node instead.\n" + - "- Intra-keg links use `[title](../NODEID)`; cross-keg links use `keg:ALIAS/NODEID` through active configuration or fully qualified `keg:@NAMESPACE/ALIAS/NODEID`.\n" + "- Attachments on a node are linked relative to that node's own directory: `[label](./assets/FILE)` for files and `![alt](./images/IMAGE)` for images. Both directory names are plural.\n" // OrientOptions is the input to Tap.Orient. Flight is the only selector used @@ -47,17 +44,68 @@ func (t *Tap) Orient(ctx context.Context, opts OrientOptions) (string, error) { } flightName := t.ActiveFlightName(opts.Flight) flight, flightNote := t.resolveOrientFlight(ctx, flightName) - available, warnings := t.orientKegListing(ctx, flight) - return BuildOrientationPayload(flight, flightNote, t.ActiveAgentName(), available, warnings) + available, warnings := t.OrientationKegsForFlight(ctx, flight) + var authority *OrientationAuthority + if strings.TrimSpace(flightName) == "" { + available, warnings = t.IdentityKegCatalog(ctx) + flightNote = "No flight is configured, so normal identity-authorized full access applies. Pin a least-privilege flight outside MCP and start a new connection to narrow it." + authority = &OrientationAuthority{FullAccess: true} + } + payload, err := BuildOrientationPayload(flight, flightNote, t.ActiveAgentName(), available, warnings, authority) + if err != nil { + return "", err + } + return strings.Replace( + payload, + "3. In MCP, call `session_refresh`, then `orient` on this same connection. The stateless CLI preview is refreshed by running `tap orient` again.", + "3. Run this stateless preview again after the user changes the selection.", + 1, + ), nil +} + +// OrientationKegsForFlight returns the effective KEG authority projection +// without rendering a payload. Providers use it to compute the revision first +// and then render exactly once. +func (t *Tap) OrientationKegsForFlight(ctx context.Context, flight *Flight) ([]OrientationKeg, []string) { + rows, warnings := t.IdentityKegCatalog(ctx) + if flight == nil { + return nil, warnings + } + return ProjectOrientationKegs(flight, rows), warnings } -// OrientationForFlight builds orientation from an already-resolved immutable -// flight snapshot. It is the MCP session path: callers resolve selection and -// refresh policy first, then atomically publish the returned payload. -func (t *Tap) OrientationForFlight(ctx context.Context, flight *Flight) (string, []OrientationKeg, []string, error) { - available, warnings := t.orientKegListing(ctx, flight) - payload, err := BuildOrientationPayload(flight, "", t.ActiveAgentName(), available, warnings) - return payload, available, warnings, err +// IdentityKegCatalog discovers the identity-authorized KEGs from every +// configured hub without applying flight authority. Each hub is queried at +// most once. Callers must explicitly project these rows through a selected +// flight or use them only for identity search. +func (t *Tap) IdentityKegCatalog(ctx context.Context) ([]OrientationKeg, []string) { + return t.identityKegCatalog(ctx) +} + +// ProjectOrientationKegs applies one flight's cover to a previously loaded +// identity projection. The returned rows retain the identity role and record +// the independent flight cap so callers can compute the lesser effective role. +func ProjectOrientationKegs(flight *Flight, rows []OrientationKeg) []OrientationKeg { + if flight == nil { + return nil + } + seen := map[string]struct{}{} + out := make([]OrientationKeg, 0, len(rows)) + for _, row := range rows { + capRole, ok := flightCapForKeg(flight, row.Namespace, row.Alias) + if !ok { + continue + } + row.FlightCap = capRole + row.Flights = []string{flight.Name} + if _, duplicate := seen[row.Ref]; duplicate { + continue + } + seen[row.Ref] = struct{}{} + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) + return out } // ActiveFlightName resolves an explicit flight, falling back to the flight in @@ -116,9 +164,21 @@ type OrientationKeg struct { Source string Visibility string FlightCap string + Flights []string } -func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]OrientationKeg, []string) { +// OrientationAuthority describes the connection-pinned launch root and the flight +// selected from its live transitive graph for this call. +type OrientationAuthority struct { + Root *Flight + Active *Flight + Path []string + AvailableFlights []string + Revision string + FullAccess bool +} + +func (t *Tap) identityKegCatalog(ctx context.Context) ([]OrientationKeg, []string) { if t == nil || t.ConfigService == nil { return nil, []string{"KEG listing unavailable: no config service is configured."} } @@ -137,7 +197,6 @@ func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]Orientati warnings = append(warnings, w.Message) } } - seen := map[string]struct{}{} var out []OrientationKeg for _, hubName := range t.allHubNames(cfg) { entry, ok := cfg.Hub(hubName) @@ -150,54 +209,25 @@ func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]Orientati continue } for _, row := range rows { - if capRole, ok := flightCapForKeg(flight, row.Namespace, row.Alias); !ok { - continue - } else { - row.FlightCap = capRole - } - if _, dup := seen[row.Ref]; dup { - continue - } - seen[row.Ref] = struct{}{} out = append(out, row) } } - sort.Slice(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) - return out, warnings -} - -func (t *Tap) orientKegsForHub(ctx context.Context, cfg *Config, hubName string, entry HubEntry) ([]OrientationKeg, error) { - kind := strings.TrimSpace(entry.Kind) - if kind == "" { - kind = HubKindRemote - } - if kind == HubKindLocal { - base, err := t.localHubBase(entry) - if err != nil { - return nil, err - } - refs := t.scanLocalHubKegs(base) - out := make([]OrientationKeg, 0, len(refs)) - for _, ref := range refs { - ns, alias, ok := splitKegRef(ref) - if !ok { - continue - } - out = append(out, OrientationKeg{ - Ref: ref, - Namespace: ns, - Alias: alias, - Role: string(FlightRoleEditor), - Source: hubName, - Visibility: "local", - }) - title, summary, _ := t.localKegDiscovery(filepath.Join(base, "@"+ns, alias)) - out[len(out)-1].Title = title - out[len(out)-1].Summary = summary + seen := map[string]struct{}{} + identity := make([]OrientationKeg, 0, len(out)) + for _, row := range out { + if _, duplicate := seen[row.Ref]; duplicate { + continue } - return out, nil + seen[row.Ref] = struct{}{} + row.FlightCap = "" + row.Flights = nil + identity = append(identity, row) } + sort.Slice(identity, func(i, j int) bool { return identity[i].Ref < identity[j].Ref }) + return identity, warnings +} +func (t *Tap) orientKegsForHub(ctx context.Context, _ *Config, hubName string, entry HubEntry) ([]OrientationKeg, error) { url := strings.TrimSpace(entry.URL) if url == "" { return nil, fmt.Errorf("hub has no url configured") @@ -206,30 +236,6 @@ func (t *Tap) orientKegsForHub(ctx context.Context, cfg *Config, hubName string, if token == "" { return nil, fmt.Errorf("hub has no authenticated session for %s", url) } - discovered, err := DiscoverOrientationKegs(ctx, url, token) - if err == nil { - out := make([]OrientationKeg, 0, len(discovered)) - for _, k := range discovered { - out = append(out, OrientationKeg{ - Ref: "@" + k.Namespace + "/" + k.Alias, - Namespace: k.Namespace, - Alias: k.Alias, - Title: k.Title, - Summary: k.Summary, - Role: k.Role, - Source: hubName, - Visibility: k.Visibility, - }) - } - return out, nil - } - if !errors.Is(err, ErrOrientationUnsupported) { - return nil, err - } - - // Compatibility path for older Hubs: retain their catalog listing and - // read each selected config for title/summary only. Instructions remain - // suppressed from aggregate orientation. kegs, err := ListUserKegs(ctx, url, token) if err != nil { return nil, err @@ -240,67 +246,20 @@ func (t *Tap) orientKegsForHub(ctx context.Context, cfg *Config, hubName string, Ref: "@" + k.Namespace + "/" + k.Alias, Namespace: k.Namespace, Alias: k.Alias, + Title: k.Title, + Summary: k.Summary, Role: k.Role, Source: hubName, Visibility: k.Visibility, } - if title, summary, configErr := t.kegDiscovery(ctx, cfg, hubName, k.Namespace, k.Alias); configErr == nil { - row.Title = title - row.Summary = summary - } out = append(out, row) } return out, nil } -func (t *Tap) kegDiscovery(ctx context.Context, cfg *Config, hubName, namespace, alias string) (string, string, error) { - if cfg == nil || alias == "" { - return "", "", nil - } - if entry, ok := cfg.Hub(hubName); ok && hubKindOrDefault(entry.Kind) == HubKindLocal { - base, err := t.localHubBase(entry) - if err != nil { - return "", "", err - } - return t.localKegDiscovery(filepath.Join(base, "@"+strings.TrimPrefix(namespace, "@"), alias)) - } - target, err := cfg.ResolveRef(t.Runtime, KegRef{Hub: hubName, Namespace: namespace, Name: alias}) - if err != nil { - return "", "", err - } - var resolver keg.TokenResolver - if t.KegService != nil { - resolver = t.KegService.tokenResolver() - } - k, err := keg.NewKegFromTarget(ctx, *target, t.Runtime, keg.WithTokenResolver(resolver)) - if err != nil { - return "", "", err - } - cfgDoc, err := k.Config(ctx) - if err != nil || cfgDoc == nil { - return "", "", err - } - return cfgDoc.Title, cfgDoc.Summary, nil -} - -func (t *Tap) localKegDiscovery(dir string) (string, string, error) { - for _, name := range []string{"keg", "keg.yaml", "keg.yml"} { - raw, err := t.Runtime.ReadFile(filepath.Join(dir, name)) - if err != nil { - continue - } - cfgDoc, err := keg.ParseKegConfig(raw) - if err != nil { - return "", "", err - } - return cfgDoc.Title, cfgDoc.Summary, nil - } - return "", "", nil -} - func flightCapForKeg(flight *Flight, namespace, alias string) (string, bool) { if flight == nil { - return "", true + return "", false } if flight.HasCapability(FlightCapabilityFullAccess) { return string(FlightRoleAdmin), true @@ -326,6 +285,43 @@ func flightCapForKeg(flight *Flight, namespace, alias string) (string, bool) { return "", false } +// EffectiveOrientationRole intersects the identity role with the flight cap. +// Identity catalog rows have no cap and therefore retain their identity role +// only for metadata search; operational projections always carry a cap. +func EffectiveOrientationRole(row OrientationKeg) string { + identity := orientationRoleRank(row.Role) + if strings.TrimSpace(row.FlightCap) == "" { + return orientationRoleName(identity) + } + capRole := orientationRoleRank(row.FlightCap) + if identity < capRole { + return orientationRoleName(identity) + } + return orientationRoleName(capRole) +} + +func orientationRoleRank(role string) int { + switch strings.TrimSpace(role) { + case string(FlightRoleAdmin): + return 3 + case string(FlightRoleEditor): + return 2 + default: + return 1 + } +} + +func orientationRoleName(rank int) string { + switch rank { + case 3: + return string(FlightRoleAdmin) + case 2: + return string(FlightRoleEditor) + default: + return string(FlightRoleViewer) + } +} + func splitKegRef(ref string) (namespace, alias string, ok bool) { ns, rest, ok := strings.Cut(strings.TrimPrefix(strings.TrimSpace(ref), "@"), "/") if !ok || ns == "" || rest == "" { @@ -348,18 +344,34 @@ func kegRefLabel(target *keg.Target) string { return name } +// OrientationOperatingRules returns the static KEG operating preamble: what a +// KEG is, and the rules for working in one. It carries no session state, so an +// MCP server can deliver it once at initialization and let a caller that +// already knows which flight to pass start work without orienting first. +// +// It remains part of the orient payload as well. That duplication is +// deliberate: initialization instructions are captured once and are discarded +// by a context reset, so orient has to stay self-contained or a compacted agent +// has no route back to these rules. +func OrientationOperatingRules() string { + return "# KEG System\n\n" + orientPurpose + "\n\n" + orientRulesSummary +} + // BuildOrientationPayload renders the provider-neutral orientation document -// from one immutable flight snapshot and its effective KEG listing. agent names +// from one flight snapshot and its effective KEG listing. agent names // the `tap launch` agent driving the session, or "" when a human is; it is // reported because it explains where the flight came from and how to change it. -func BuildOrientationPayload(flight *Flight, flightNote, agent string, kegs []OrientationKeg, warnings []string) (string, error) { +func BuildOrientationPayload(flight *Flight, flightNote, agent string, kegs []OrientationKeg, warnings []string, authority *OrientationAuthority) (string, error) { var b strings.Builder - b.WriteString("# KEG System\n\n") - b.WriteString(orientPurpose) - b.WriteString("\n\n") - b.WriteString(orientRulesSummary) + b.WriteString(OrientationOperatingRules()) b.WriteString("\n") + // A graph-wide listing mixes KEGs the active flight covers itself with KEGs + // only a descendant covers. They are operationally different — the second + // group needs a flight selection first — so they are rendered apart rather + // than distinguished only by a column an agent can skim past. + usable, viaSubflight := partitionOrientationKegs(flight, kegs) + b.WriteString("## Available KEGs\n\n") if len(warnings) > 0 { for _, warning := range warnings { @@ -369,91 +381,48 @@ func BuildOrientationPayload(flight *Flight, flightNote, agent string, kegs []Or } b.WriteString("\n") } - if len(kegs) == 0 { - b.WriteString("(No KEGs are currently available from configured hubs") - if flight != nil && len(flight.Cover) > 0 { - b.WriteString(" after applying the active flight cover") - } - b.WriteString(".)\n\n") - } else { - b.WriteString("| KEG | Title | Summary | Role | Source | Flight cap |\n") - b.WriteString("| --- | --- | --- | --- | --- | --- |\n") - for _, k := range kegs { - role := k.Role - if role == "" { - role = "viewer" + if len(usable) == 0 { + if len(viaSubflight) > 0 { + // The dispatcher shape: a parent flight that carries instructions and + // delegates every KEG to a descendant. Saying "no KEGs available" + // here would be wrong and would stop an agent that should be reading + // the next section instead. + b.WriteString("(The active flight covers no KEGs directly. See \"Reachable via subflight\" below.)\n\n") + } else { + b.WriteString("(No KEGs are currently available from configured hubs") + if flight != nil && len(flight.Cover) > 0 { + b.WriteString(" after applying the active flight cover") } - capRole := k.FlightCap - if capRole == "" { - capRole = "none" - } - source := k.Source - if k.Visibility != "" { - source += "/" + k.Visibility - } - fmt.Fprintf( - &b, - "| `%s` | %s | %s | %s | %s | %s |\n", - k.Ref, - orientationTableCell(k.Title), - orientationTableCell(k.Summary), - role, - source, - capRole, - ) + b.WriteString(".)\n\n") } + } else { + writeOrientationKegTable(&b, usable, "Flights") b.WriteString("\nCall `keg_settings` for the selected KEG or KEGs before operating in them; targeted settings include KEG-level instructions.\n\n") } - if flight == nil { - // Recovery. Say so in the payload itself: the tool list is filtered to - // the recovery set, so an agent never gets to call a locked tool and - // see the error explaining why. Without this the only signal is an - // absence — an empty KEG table — which weaker models do not act on. - b.WriteString("## Flight\n\n") - b.WriteString("No flight is selected, so this session is in recovery mode ") - b.WriteString("and the KEG tools are locked. Only `orient`, `list_flights`, ") - b.WriteString("`flight_show`, and `auth_info` are available.\n\n") - b.WriteString("To recover:\n\n") - b.WriteString("1. Call `list_flights` to see what is available.\n") - b.WriteString("2. Ask the user to select a flight in Tapper configuration. ") - b.WriteString("Flights are selected outside MCP; an agent cannot select one itself.\n") - b.WriteString("3. Call `orient` again on this same connection to pick it up.\n\n") - if agent != "" { - b.WriteString("This session was launched as agent `") - b.WriteString(agent) - b.WriteString("`, so giving that agent a `flight` in Tapper configuration ") - b.WriteString("is the most direct fix.\n\n") - } + if len(viaSubflight) > 0 { + b.WriteString("## Reachable via subflight\n\n") + b.WriteString("The active flight does not cover these KEGs, so the KEG tools cannot reach them yet. ") + b.WriteString("Pass the named flight as the `flight` argument on a tool call to operate in one. ") + b.WriteString("That selection applies to a single call and never changes this session's pinned root.\n\n") + writeOrientationKegTable(&b, viaSubflight, "Select flight") + b.WriteString("\n") } if flight != nil { b.WriteString("## Flight\n\n") - switch { - case flight.Bootstrap: - // Never say "active flight" for the synthetic one: a reader who - // believes a flight was selected will not go set one up, which is - // the entire point of this mode. - b.WriteString("No flight is configured, so this session is running on a ") - b.WriteString("temporary bootstrap flight. Its cover is empty, so every KEG ") - b.WriteString("tool stays locked; what it grants is the authority to create ") - b.WriteString("the first flight and the first KEG. Setting this up is the ") - b.WriteString("session's work — do it before anything else.\n\n") - case flight.Name != "": + if flight.Name != "" { b.WriteString("Active flight: `") b.WriteString(flight.Name) b.WriteString("`\n\n") } if agent != "" { - // Naming the agent tells the reader where the flight came from and - // how to move it. Without this the flight looks like a fixed - // property of the session, and the user is told to edit `flight:` - // in config — which the agent's own flight silently outranks. b.WriteString("This session is driven by agent `") b.WriteString(agent) - b.WriteString("`. Unless `TAP_FLIGHT` or `--flight` overrides it, the flight above ") - b.WriteString("comes from that agent's `flight` in Tapper configuration: change it ") - b.WriteString("there and call `orient` again to move this session.\n\n") + b.WriteString("`. The agent selects only the model and telemetry identity; it cannot ") + b.WriteString("select or replace the connection-pinned root in `TAP_FLIGHT`. Call ") + b.WriteString("`orient` without a flight to use that root, or name an authorized ") + b.WriteString("descendant to work under only that flight's instructions and authority.\n\n") } if flightNote != "" { b.WriteString(flightNote) @@ -470,9 +439,67 @@ func BuildOrientationPayload(flight *Flight, flightNote, agent string, kegs []Or b.WriteString("(No flight-level instructions.)\n\n") } } + if flight == nil { + b.WriteString("## Flight\n\n") + if authority != nil && authority.FullAccess { + b.WriteString("No flight was provided. Normal identity-authorized full access applies, so every KEG is available only at the caller's real role; Hub ACLs and namespace membership are never raised or bypassed.\n\n") + if flightNote != "" { + b.WriteString(strings.TrimSpace(flightNote)) + b.WriteString("\n\n") + } + } else { + b.WriteString("The explicitly selected flight could not be activated, so this session is in fail-closed recovery and KEG tools are locked. Only `orient`, `session_refresh`, `list_flights`, `flight_show`, `auth_info`, and `keg_search` are available.\n\n") + if flightNote != "" { + b.WriteString(strings.TrimSpace(flightNote)) + b.WriteString("\n\n") + } + b.WriteString("Repair that exact selection outside MCP, then call `session_refresh` and `orient`. This state never falls back to no-flight full access.\n\n") + } + } + + if authority != nil && (authority.Root != nil || authority.FullAccess) { + active := authority.Active + if active == nil { + active = flight + } + b.WriteString("## Orientation authority\n\n") + if authority.Root != nil { + b.WriteString("Launch root: `" + authority.Root.Name + "`\n\n") + } else { + b.WriteString("Launch root: (none; identity-authorized full access)\n\n") + } + if active != nil { + b.WriteString("Selected flight: `" + active.Name + "`\n\n") + } + path := authority.Path + if len(path) == 0 && authority.Root != nil { + path = []string{authority.Root.Name} + } + if len(path) > 0 { + b.WriteString("Resolved path: `" + strings.Join(path, "` → `") + "`\n\n") + } + b.WriteString("Selectable flights:") + if len(authority.AvailableFlights) == 0 { + b.WriteString(" (none)\n\n") + } else { + b.WriteString("\n\n") + for _, ref := range authority.AvailableFlights { + b.WriteString("- `" + ref + "`\n") + } + b.WriteString("\n") + } + if authority.Revision != "" { + b.WriteString("Authority revision: `" + authority.Revision + "`\n\n") + } + if authority.FullAccess { + b.WriteString("The absence of a flight is pinned to this MCP connection. Bare calls use normal identity-authorized full access and send no governed-flight state. An explicit `flight` selects exactly one identity-accessible real flight for that call and uses only its cover, capabilities, and instructions. Concurrent callers may select different real flights without changing shared session state. Pin a least-privilege flight outside MCP, then start a new connection to narrow access.\n\n") + } else { + b.WriteString("The root reference is pinned to this MCP connection. Every authority-bearing call reloads its live transitive graph. Default `orient` and `keg_list` discovery summarize the root plus accessible descendants; explicitly supplying `flight` discovers exactly that flight. Operational tools still use only the root when `flight` is omitted, while an explicit root or listed descendant uses only that flight's instructions and authority. Descendant cover, capabilities, and instructions are never inherited. Concurrent callers may select different descendants without changing shared session state. Use `keg_search` to find identity-accessible KEGs outside this graph; results grant no operational access.\n\n") + } + } b.WriteString("## Guidance\n\n") - for _, name := range []string{"linking.md", "snapshot-policy.md", "secret-handling.md", "agent-orient.md", "tool-inventory.md", "troubleshooting.md"} { + for _, name := range []string{"snapshot-policy.md", "secret-handling.md", "agent-orient.md", "tool-inventory.md", "linking.md", "troubleshooting.md"} { if err := appendCanonical(&b, name); err != nil { return "", err } @@ -482,6 +509,65 @@ func BuildOrientationPayload(flight *Flight, flightNote, agent string, kegs []Or return b.String(), nil } +// partitionOrientationKegs splits a listing into KEGs the active flight covers +// itself and KEGs only a descendant covers. +// +// Membership and the reported role both come from the active flight's own +// cover, never from the row's Flights provenance. AggregateOrientationKegs +// retains every granting flight while pricing the row at the highest effective +// role, so provenance alone cannot say which role this particular call gets. + +// Re-price against the active flight instead: otherwise a lower-cap root could +// quote a descendant's higher role or hide a KEG behind a selection it does not +// need. +// +// A nil flight with rows is no-flight full access, where the identity listing +// is already the operational projection. Failed-root recovery passes no rows. +func partitionOrientationKegs(flight *Flight, kegs []OrientationKeg) (usable, viaSubflight []OrientationKeg) { + if flight == nil { + return kegs, nil + } + for _, k := range kegs { + capRole, covered := flightCapForKeg(flight, k.Namespace, k.Alias) + if !covered { + viaSubflight = append(viaSubflight, k) + continue + } + // Re-price against the active flight so the displayed role is the one + // this call would actually get. + k.FlightCap = capRole + k.Flights = []string{flight.Name} + usable = append(usable, k) + } + return usable, viaSubflight +} + +func writeOrientationKegTable(b *strings.Builder, kegs []OrientationKeg, flightsHeading string) { + fmt.Fprintf(b, "| KEG | Title | Summary | Role | %s | Source |\n", flightsHeading) + b.WriteString("| --- | --- | --- | --- | --- | --- |\n") + for _, k := range kegs { + role := EffectiveOrientationRole(k) + flights := "none" + if len(k.Flights) > 0 { + flights = strings.Join(k.Flights, ", ") + } + source := k.Source + if k.Visibility != "" { + source += "/" + k.Visibility + } + fmt.Fprintf( + b, + "| `%s` | %s | %s | %s | %s | %s |\n", + k.Ref, + orientationTableCell(k.Title), + orientationTableCell(k.Summary), + role, + orientationTableCell(flights), + source, + ) + } +} + func orientationTableCell(value string) string { value = strings.TrimSpace(value) value = strings.ReplaceAll(value, "\\", "\\\\") diff --git a/pkg/tapper/tap_orient_test.go b/pkg/tapper/tap_orient_test.go index aaed895a..9017bdd5 100644 --- a/pkg/tapper/tap_orient_test.go +++ b/pkg/tapper/tap_orient_test.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "path/filepath" "strings" "sync/atomic" "testing" @@ -41,17 +40,22 @@ func TestTap_Orient_SharedPayloadStartsWithKegSystem(t *testing.T) { require.True(t, strings.HasPrefix(payload, "# KEG System\n\n"), payload) require.Contains(t, payload, "Tapper provides an MCP interface for KEG") - require.NotContains(t, payload, "CLI") require.NotContains(t, payload, "`tap ") require.Contains(t, payload, "Rules:") require.NotContains(t, payload, "## Active KEG") require.Contains(t, payload, "## Available KEGs") require.NotContains(t, payload, "## KEG Instructions") require.Contains(t, payload, "## Guidance") - require.Contains(t, payload, "# Linking conventions") guidance := payload[strings.Index(payload, "## Guidance"):] - require.Contains(t, guidance, "`keg:ALIAS/NODEID`") - require.Contains(t, guidance, "`keg:@NAMESPACE/ALIAS/NODEID`") + require.Contains(t, guidance, "# Linking conventions") + for _, exact := range []string{ + "[title](../NODEID)", + "[title](keg:ALIAS/NODEID)", + "[title](keg:@NAMESPACE/ALIAS/NODEID)", + } { + require.Contains(t, guidance, exact) + } + require.Contains(t, guidance, "A bare `keg:` reference in node prose is plain text") require.Contains(t, payload, "# Snapshot policy") require.NotContains(t, payload, "## Host:") require.NotContains(t, strings.ToLower(payload), "tier 0") @@ -72,159 +76,6 @@ func TestTap_Orient_UnknownFlightEmitsNote(t *testing.T) { require.Contains(t, payload, `Flight "f-demo" is unavailable`) } -func TestTap_Orient_FlightInstructionsAndKegDiscoveryPrecedeGuidance(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - require.NoError(t, sb.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()}) - require.NoError(t, err) - - require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), - []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644)) - for _, name := range []string{"personal", "dev"} { - dir := "/home/testuser/kegs/@local/" + name - require.NoError(t, sb.Runtime().Mkdir(dir, 0o755, true)) - require.NoError(t, sb.Runtime().AtomicWriteFile(dir+"/keg", []byte("kegv: 2025-07\ntitle: "+name+"\n"), 0o644)) - } - require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/personal/keg", - []byte("kegv: 2025-07\ntitle: Personal\nsummary: Personal discovery text.\ninstructions: |\n Prefer audited personal-context nodes.\n"), 0o644)) - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/backend.yaml", - []byte("title: Backend\ncover:\n - namespace: local\n keg: personal\n role: viewer\n - namespace: local\n keg: dev\n role: editor\ninstructions: |\n Touch only backend kegs.\n"), 0o644)) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{ - KegTargetOptions: tapper.KegTargetOptions{Flight: "backend"}, - }) - require.NoError(t, err) - require.Contains(t, payload, "| `@local/dev` | dev | — | editor | home/local | editor |") - require.Contains(t, payload, "| `@local/personal` | Personal | Personal discovery text. | editor | home/local | viewer |") - require.Contains(t, payload, "## Flight") - require.Contains(t, payload, "Backend") - require.Contains(t, payload, "Touch only backend kegs.") - require.NotContains(t, payload, "## KEG Instructions") - require.NotContains(t, payload, "Prefer audited personal-context nodes.") - require.Contains(t, payload, "Call `keg_settings`") - - guidanceAt := strings.Index(payload, "## Guidance") - require.NotEqual(t, -1, guidanceAt) - require.Less(t, strings.Index(payload, "Touch only backend kegs."), guidanceAt) - require.Less(t, strings.Index(payload, "Call `keg_settings`"), guidanceAt) -} - -func TestTap_Orient_UsesPersistedFlightBeforeDefaultKeg(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - require.NoError(t, sb.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()}) - require.NoError(t, err) - require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`flight: backend -fallbackKeg: personal -hubs: - home: - kind: local - defaultNamespace: local - basePath: /home/testuser/kegs -`), 0o644)) - for _, name := range []string{"personal", "dev"} { - dir := "/home/testuser/kegs/@local/" + name - require.NoError(t, sb.Runtime().Mkdir(dir, 0o755, true)) - require.NoError(t, sb.Runtime().AtomicWriteFile(dir+"/keg", []byte("kegv: 2025-07\ntitle: "+name+"\n"), 0o644)) - } - require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/dev/keg", []byte("kegv: 2025-07\ntitle: dev\ninstructions: |\n Follow the covered KEG schema.\n"), 0o644)) - require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/kegs/flights.d/backend.yaml", []byte("title: Backend\ncover:\n - namespace: local\n keg: dev\n role: editor\ninstructions: |\n Flight instructions win.\n"), 0o644)) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) - require.NoError(t, err) - require.Contains(t, payload, "Active flight: `@local/+backend`") - require.Contains(t, payload, "Flight instructions win.") - require.NotContains(t, payload, "Follow the covered KEG schema.") - require.NotContains(t, payload, "## KEG Instructions") - require.NotContains(t, payload, "| `@local/personal`") -} - -// TestTap_OrientReloadsNearestProjectConfig covers the reload boundary. Orient -// owns the cache reset; ActiveFlightName is a pure read of whatever cascade is -// currently loaded, so a stale cache stays stale until Orient refreshes it. -func TestTap_OrientReloadsNearestProjectConfig(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - project := "/home/testuser/project" - descendant := filepath.Join(project, "src", "pkg") - require.NoError(t, sb.Setwd(descendant)) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: descendant, Runtime: sb.Runtime()}) - require.NoError(t, err) - require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`flight: +baseline -fallbackNamespace: local -hubs: - home: - kind: local - basePath: /home/testuser/kegs -`), 0o644)) - - // Prime the merged cache before the project config exists. Orientation must - // still reload the cascade and adopt the nearest project selection. - cfg, err := tap.ConfigService.Config() - require.NoError(t, err) - require.Equal(t, "+baseline", cfg.Flight()) - require.NoError(t, sb.Runtime().AtomicWriteFile( - filepath.Join(project, ".tapper", "config.yaml"), - []byte("flight: +project\n"), 0o644)) - - for _, flight := range []struct{ slug, title string }{ - {"baseline", "Baseline"}, - {"project", "Project"}, - } { - require.NoError(t, sb.Runtime().AtomicWriteFile( - filepath.Join("/home/testuser/kegs/flights.d", flight.slug+".yaml"), - []byte("title: "+flight.title+"\ninstructions: "+flight.title+" instructions\n"), 0o644)) - } - - // The primed cache still answers with the user-level baseline, because a - // pure read must not silently reload behind the caller's back. - require.Equal(t, "+baseline", tap.ActiveFlightName("")) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) - require.NoError(t, err) - require.Contains(t, payload, "+project") - require.Contains(t, payload, "Project instructions") - require.NotContains(t, payload, "Baseline instructions") - - // Orient reloaded the cascade, so the pure read now sees the project value. - require.Equal(t, "+project", tap.ActiveFlightName("")) -} - -func TestTap_Orient_FullAccessStillSuppressesKegInstructions(t *testing.T) { - t.Parallel() - sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"}) - require.NoError(t, sb.Setwd("/home/testuser")) - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()}) - require.NoError(t, err) - - require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), - []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644)) - dir := "/home/testuser/kegs/@local/dev" - require.NoError(t, sb.Runtime().Mkdir(dir, 0o755, true)) - require.NoError(t, sb.Runtime().AtomicWriteFile(dir+"/keg", []byte( - "kegv: 2025-07\ntitle: Development\nsummary: Discoverable engineering context.\ninstructions: DO NOT LEAK FULL ACCESS GUIDANCE\n", - ), 0o644)) - require.NoError(t, sb.Runtime().AtomicWriteFile( - "/home/testuser/kegs/flights.d/full.yaml", - []byte("title: Full access\ncapabilities: [full_access]\ninstructions: Flight guidance remains visible.\n"), - 0o644, - )) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{ - KegTargetOptions: tapper.KegTargetOptions{Flight: "full"}, - }) - require.NoError(t, err) - require.Contains(t, payload, "Discoverable engineering context.") - require.Contains(t, payload, "Flight guidance remains visible.") - require.Contains(t, payload, "| admin |") - require.NotContains(t, payload, "DO NOT LEAK FULL ACCESS GUIDANCE") - require.NotContains(t, payload, "## KEG Instructions") -} - func TestTap_Orient_BarePayloadDoesNotInjectDeveloperLifecycle(t *testing.T) { t.Parallel() payload, err := newOrientTap(t).Orient(context.Background(), tapper.OrientOptions{}) @@ -261,17 +112,16 @@ func TestTap_Orient_MissingHubAuthenticationIsMCPFirst(t *testing.T) { payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) require.NoError(t, err) require.Contains(t, payload, `skipped hub "work": hub has no authenticated session for https://hub.example.com`) - require.NotContains(t, payload, "CLI") require.NotContains(t, payload, "`tap ") } -func TestTap_Orient_CompatibleRemoteUsesOneDiscoveryRequest(t *testing.T) { +func TestTap_IdentityKegCatalog_UsesOneKegCatalogRequest(t *testing.T) { t.Parallel() var requests atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - require.Equal(t, "/api/v1/orient", r.URL.Path) - _ = json.NewEncoder(w).Encode([]tapper.HubOrientationKeg{{ + require.Equal(t, "/api/v1/kegs", r.URL.Path) + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{{ Namespace: "foldwise", Alias: "dev", Title: "Development", @@ -288,34 +138,29 @@ func TestTap_Orient_CompatibleRemoteUsesOneDiscoveryRequest(t *testing.T) { cfg := fmt.Sprintf("hubs:\n test:\n kind: remote\n url: %s\n token: token\n", srv.URL) require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644)) - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) - require.NoError(t, err) + rows, warnings := tap.IdentityKegCatalog(context.Background()) require.EqualValues(t, 1, requests.Load()) - require.Contains(t, payload, "| `@foldwise/dev` | Development | Engineering system of record. | admin | test/private | none |") - require.NotContains(t, payload, "## KEG Instructions") + require.Empty(t, warnings) + require.Equal(t, []tapper.OrientationKeg{{ + Ref: "@foldwise/dev", Namespace: "foldwise", Alias: "dev", + Title: "Development", Summary: "Engineering system of record.", + Visibility: "private", Role: "admin", Source: "test", + }}, rows) } -func TestTap_Orient_OlderHubFallbackSuppressesInstructions(t *testing.T) { +func TestTap_IdentityKegCatalog_NeverReadsIndividualSettings(t *testing.T) { t.Parallel() var configReads atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v1/orient": - http.NotFound(w, r) case "/api/v1/kegs": _ = json.NewEncoder(w).Encode([]tapper.HubKeg{{ - Namespace: "foldwise", - Alias: "dev", - Role: "admin", + Namespace: "foldwise", Alias: "dev", Title: "Catalog title", + Summary: "Catalog summary.", Role: "admin", }}) - case "/api/v1/@foldwise/kegs/dev/config": + case "/api/v1/@foldwise/kegs/dev/settings": configReads.Add(1) - _ = json.NewEncoder(w).Encode(map[string]any{ - "kegv": "2025-07", - "title": "Fallback title", - "summary": "Fallback summary.", - "instructions": "DO NOT LEAK FALLBACK INSTRUCTIONS", - }) + http.Error(w, "aggregate discovery must not read settings", http.StatusInternalServerError) default: http.NotFound(w, r) } @@ -328,78 +173,25 @@ func TestTap_Orient_OlderHubFallbackSuppressesInstructions(t *testing.T) { cfg := fmt.Sprintf("hubs:\n test:\n kind: remote\n url: %s\n token: token\n", srv.URL) require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644)) - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) - require.NoError(t, err) - require.EqualValues(t, 1, configReads.Load()) - require.Contains(t, payload, "Fallback title") - require.Contains(t, payload, "Fallback summary.") - require.NotContains(t, payload, "DO NOT LEAK FALLBACK INSTRUCTIONS") - require.NotContains(t, payload, "## KEG Instructions") -} - -// TestTap_Orient_ActiveKeg_AliasResolutionFromCwd covers the common -// case: a kegMap entry whose pathPrefix matches the working directory -// resolves the keg from cwd. The keg lives on the local hub, so the -// resolved target is a bare file backend with no keg name; the active-keg -// line surfaces the path-free backend label with a "no alias" suffix and -// never leaks the underlying filesystem location. -func TestTap_Orient_ActiveKeg_AliasResolutionFromCwd(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - root := "/home/testuser/work" - require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: root, Runtime: fx.Runtime()}) - require.NoError(t, err) - - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.UserConfig()), 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`fallbackKeg: notes -fallbackNamespace: local -kegMap: - - alias: notes - pathPrefix: ~/work -hubs: - home: - kind: local - basePath: ~/Documents/kegs -`), 0o644)) - require.NoError(t, fx.Runtime().Mkdir("/home/testuser/Documents/kegs/@local/notes", 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile("/home/testuser/Documents/kegs/@local/notes/keg", []byte("kegv: 2025-07\n"), 0o644)) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) - require.NoError(t, err) - require.NotContains(t, payload, "Active KEG:") - require.NotContains(t, payload, "Documents/kegs/notes") + rows, warnings := tap.IdentityKegCatalog(context.Background()) + require.EqualValues(t, 0, configReads.Load()) + require.Empty(t, warnings) + require.Len(t, rows, 1) + require.Equal(t, "Catalog title", rows[0].Title) + require.Equal(t, "Catalog summary.", rows[0].Summary) } -// TestTap_Orient_ActiveKeg_NoAliasFallback covers a project-local keg -// resolved from the working directory but not registered under any -// alias in tap config. -func TestTap_Orient_ActiveKeg_NoAliasFallback(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - root := "/home/testuser/loose" - kegDir := root + "/kegs/loose" - require.NoError(t, fx.Runtime().Mkdir(kegDir, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(kegDir+"/keg", []byte("kegv: 2025-07\n"), 0o644)) - require.NoError(t, fx.Setwd(root)) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: root, Runtime: fx.Runtime()}) - require.NoError(t, err) - - payload, err := tap.Orient(context.Background(), tapper.OrientOptions{ - KegTargetOptions: tapper.KegTargetOptions{Project: true}, - }) - require.NoError(t, err) - require.NotContains(t, payload, "Active KEG:") - require.NotContains(t, payload, "loose/kegs/loose") -} - -// TestTap_Orient_ActiveKeg_ExplicitOverride confirms that an explicit -// keg passed through OrientOptions wins over auto-resolution from cwd. +// Explicit KEG selection does not alter the orientation catalog or choose MCP +// authority; flight selection remains configuration-owned. func TestTap_Orient_ActiveKeg_ExplicitOverride(t *testing.T) { t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/kegs", r.URL.Path) + _ = json.NewEncoder(w).Encode([]tapper.HubKeg{{ + Namespace: "local", Alias: "archive", Title: "Archive", Role: "admin", + }}) + })) + defer srv.Close() fx := NewSandbox(t) root := "/home/testuser/work" require.NoError(t, fx.Runtime().Mkdir(root, 0o755, true)) @@ -408,24 +200,15 @@ func TestTap_Orient_ActiveKeg_ExplicitOverride(t *testing.T) { tap, err := tapper.NewTap(tapper.TapOptions{Root: root, Runtime: fx.Runtime()}) require.NoError(t, err) - require.NoError(t, fx.Runtime().Mkdir(filepath.Dir(tap.PathService.UserConfig()), 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(`fallbackNamespace: local -hubs: - home: - kind: local - basePath: ~/Documents/kegs -`), 0o644)) - for _, dir := range []string{"/home/testuser/Documents/kegs/@local/archive", "/home/testuser/Documents/kegs/@local/notes"} { - require.NoError(t, fx.Runtime().Mkdir(dir, 0o755, true)) - require.NoError(t, fx.Runtime().AtomicWriteFile(dir+"/keg", []byte("kegv: 2025-07\n"), 0o644)) - } + config := fmt.Sprintf("fallbackNamespace: local\nhubs:\n home:\n kind: remote\n url: %s\n token: test-token\n", srv.URL) + require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(config), 0o644)) payload, err := tap.Orient(context.Background(), tapper.OrientOptions{ KegTargetOptions: tapper.KegTargetOptions{Keg: "archive"}, }) require.NoError(t, err) require.NotContains(t, payload, "Active KEG:") - require.NotContains(t, payload, "Documents/kegs/archive") + require.Contains(t, payload, "@local/archive") } func TestTap_IntegrateHosts_IsSortedAndIncludesDefaults(t *testing.T) { @@ -439,32 +222,23 @@ func TestTap_IntegrateHosts_IsSortedAndIncludesDefaults(t *testing.T) { } } -// TestTap_Orient_RecoveryPayloadStatesTheSituation pins the recovery guidance. -// The MCP tool list is filtered to the recovery set, so an agent never gets to -// call a locked tool and see the error explaining why — which left the empty -// KEG table as the only signal, and weaker models do not act on an absence. -func TestTap_Orient_RecoveryPayloadStatesTheSituation(t *testing.T) { +func TestTap_Orient_UnpinnedPayloadUsesFullAccess(t *testing.T) { t.Parallel() tap := newOrientTap(t) payload, err := tap.Orient(context.Background(), tapper.OrientOptions{}) require.NoError(t, err) - require.Contains(t, payload, "No flight is selected") - require.Contains(t, payload, "recovery mode") - require.Contains(t, payload, "KEG tools are locked") - require.Contains(t, payload, "`list_flights`") - require.Contains(t, payload, "Call `orient` again") + require.Contains(t, payload, "No flight was provided") + require.Contains(t, payload, "identity-authorized full access") + require.Contains(t, payload, "least-privilege flight") + require.Contains(t, payload, "start a new connection") // The payload is the MCP-facing surface and never names CLI commands. require.NotContains(t, payload, "`tap ") } -// TestTap_Orient_StatesZeroNodeAndAttachmentPaths pins two things the payload -// must carry. Node 0 is the placeholder landing node agents kept overwriting, -// and the attachment directories are plural — `assets/` and `images/`, per -// keg.NodeAttachmentsDir and keg.NodeImagesDir. A singular path in the guidance -// would produce links that upload fine and silently resolve to nothing, so the -// spelling is asserted rather than trusted. +// TestTap_Orient_StatesZeroNodeAndAttachmentPaths pins two compact safety rules +// the runtime payload must carry alongside canonical link teaching. func TestTap_Orient_StatesZeroNodeAndAttachmentPaths(t *testing.T) { t.Parallel() tap := newOrientTap(t) @@ -489,3 +263,115 @@ func TestTap_Orient_StatesZeroNodeAndAttachmentPaths(t *testing.T) { require.NotContains(t, payload, wrong) } } + +// rowFor returns the rendered table row for a KEG ref, so a test can assert on +// the columns of one row rather than on the whole document. +func rowFor(t *testing.T, payload, ref string) string { + t.Helper() + for _, line := range strings.Split(payload, "\n") { + if strings.HasPrefix(line, "| `"+ref+"`") { + return line + } + } + t.Fatalf("no table row for %q in payload:\n%s", ref, payload) + return "" +} + +func orientFlight(name, namespace, slug string, cover ...tapper.FlightCover) *tapper.Flight { + return &tapper.Flight{ + Name: name, Namespace: namespace, Slug: slug, Source: "atlas", + FlightManifest: tapper.FlightManifest{Cover: cover}, + } +} + +func TestBuildOrientationPayload_SplitsCoveredKegsFromSubflightOnlyKegs(t *testing.T) { + t.Parallel() + root := orientFlight("@ada/+root", "ada", "root", + tapper.FlightCover{Namespace: "ada", Keg: "covered", Role: tapper.FlightRoleEditor}) + // A graph-wide listing: one KEG the root covers, one only a descendant does. + kegs := []tapper.OrientationKeg{ + {Ref: "@ada/covered", Namespace: "ada", Alias: "covered", Role: "admin", + FlightCap: "editor", Flights: []string{"@ada/+root"}, Source: "atlas", Visibility: "private"}, + {Ref: "@ada/childonly", Namespace: "ada", Alias: "childonly", Role: "admin", + FlightCap: "editor", Flights: []string{"@ada/+child"}, Source: "atlas", Visibility: "private"}, + } + + payload, err := tapper.BuildOrientationPayload(root, "", "", kegs, nil, nil) + require.NoError(t, err) + + usable, viaSubflight, found := strings.Cut(payload, "## Reachable via subflight") + require.True(t, found, "expected a subflight section:\n%s", payload) + require.Contains(t, usable, "@ada/covered") + require.NotContains(t, usable, "@ada/childonly") + require.Contains(t, viaSubflight, "@ada/childonly") + require.Contains(t, viaSubflight, "@ada/+child", "the row names the flight to select") + require.NotContains(t, viaSubflight, "@ada/covered") +} + +func TestBuildOrientationPayload_PartitionsByActiveCoverNotAggregateProvenance(t *testing.T) { + t.Parallel() + root := orientFlight("@ada/+root", "ada", "root", + tapper.FlightCover{Namespace: "ada", Keg: "shared", Role: tapper.FlightRoleViewer}) + // AggregateOrientationKegs keeps only the winning grant, so this row names + // the descendant and carries the descendant's editor cap even though the + // root covers the same KEG at viewer. Partitioning on the Flights column + // would hide a readable KEG behind a flight selection it does not need. + kegs := []tapper.OrientationKeg{ + {Ref: "@ada/shared", Namespace: "ada", Alias: "shared", Role: "admin", + FlightCap: "editor", Flights: []string{"@ada/+child"}, Source: "atlas", Visibility: "private"}, + } + + payload, err := tapper.BuildOrientationPayload(root, "", "", kegs, nil, nil) + require.NoError(t, err) + + require.NotContains(t, payload, "## Reachable via subflight", + "the active flight covers this KEG, so it is usable now") + row := rowFor(t, payload, "@ada/shared") + require.Contains(t, row, "viewer", "role is re-priced to the active flight's cap") + require.NotContains(t, row, "editor", "the descendant's higher cap must not be quoted") + require.Contains(t, row, "@ada/+root", "the row is attributed to the active flight") + + // Re-pricing must not reach back into the caller's rows. Providers hand the + // same slice to FinalizeOrientation, which hashes Ref/Role/Visibility and + // FlightCap into the authority revision, so partitioning in place would + // move every revision and stale every governed request. + require.Equal(t, "editor", kegs[0].FlightCap, "caller rows must not be mutated") + require.Equal(t, []string{"@ada/+child"}, kegs[0].Flights) +} + +func TestBuildOrientationPayload_EmptyCoverWithSubflightKegsPointsAtTheNextSection(t *testing.T) { + t.Parallel() + // The dispatcher shape: a root that carries instructions and no cover, and + // delegates every KEG to a descendant. Reporting "no KEGs available" here + // would stop an agent that should be reading the next section instead. + root := orientFlight("@admin/+admin", "admin", "admin") + kegs := []tapper.OrientationKeg{ + {Ref: "@admin/private", Namespace: "admin", Alias: "private", Role: "editor", + FlightCap: "editor", Flights: []string{"@admin/+test"}, Source: "atlas", Visibility: "private"}, + } + + payload, err := tapper.BuildOrientationPayload(root, "", "", kegs, nil, nil) + require.NoError(t, err) + + require.Contains(t, payload, "The active flight covers no KEGs directly") + require.NotContains(t, payload, "No KEGs are currently available") + require.Contains(t, payload, "## Reachable via subflight") + require.Contains(t, payload, "@admin/+test") +} + +func TestBuildOrientationPayload_SingleFlightProjectionRendersOneTable(t *testing.T) { + t.Parallel() + // Supplying an explicit flight projects exactly that flight, so every row + // is covered and there is nothing to defer to a selection. + root := orientFlight("@ada/+child", "ada", "child", + tapper.FlightCover{Namespace: "ada", Keg: "notes", Role: tapper.FlightRoleEditor}) + kegs := []tapper.OrientationKeg{ + {Ref: "@ada/notes", Namespace: "ada", Alias: "notes", Role: "admin", + FlightCap: "editor", Flights: []string{"@ada/+child"}, Source: "atlas", Visibility: "private"}, + } + + payload, err := tapper.BuildOrientationPayload(root, "", "", kegs, nil, nil) + require.NoError(t, err) + require.Contains(t, payload, "@ada/notes") + require.NotContains(t, payload, "## Reachable via subflight") +} diff --git a/pkg/tapper/tap_remove.go b/pkg/tapper/tap_remove.go index 6c79bb24..4c32b491 100644 --- a/pkg/tapper/tap_remove.go +++ b/pkg/tapper/tap_remove.go @@ -18,6 +18,9 @@ type RemoveOptions struct { // Query is an optional boolean expression (tags and/or key=value attr // predicates) that selects additional nodes to remove. Query string + + ExpectedHash string + ExpectedHashes map[string]string } func (t *Tap) Remove(ctx context.Context, opts RemoveOptions) error { @@ -26,7 +29,7 @@ func (t *Tap) Remove(ctx context.Context, opts RemoveOptions) error { return fmt.Errorf("unable to open keg: %w", err) } - ids := make([]keg.NodeId, 0, len(opts.NodeIDs)) + nodes := make([]keg.NodeRemoveOptions, 0, len(opts.NodeIDs)) for _, nodeID := range opts.NodeIDs { // Intentionally NOT routed through resolveNodeArg. Query-derived ids come // from the current keg's dex and are bare; mixing them with cross-keg @@ -38,9 +41,13 @@ func (t *Tap) Remove(ctx context.Context, opts RemoveOptions) error { return err } - ids = append(ids, id) + expectedHash := opts.ExpectedHash + if hash := opts.ExpectedHashes[nodeID]; hash != "" { + expectedHash = hash + } + nodes = append(nodes, keg.NodeRemoveOptions{ID: id, ExpectedHash: expectedHash}) } - result, err := k.RemoveNodes(ctx, keg.RemoveNodesOptions{NodeIDs: ids, Query: strings.TrimSpace(opts.Query)}) + result, err := k.RemoveNodes(ctx, keg.RemoveNodesOptions{Nodes: nodes, Query: strings.TrimSpace(opts.Query)}) if errors.Is(err, keg.ErrNotExist) { return fmt.Errorf("node not found in %s: %w", describeKeg(k), err) } diff --git a/pkg/tapper/tap_schema.go b/pkg/tapper/tap_schema.go index b152464b..680d9e24 100644 --- a/pkg/tapper/tap_schema.go +++ b/pkg/tapper/tap_schema.go @@ -6,27 +6,27 @@ import ( "errors" "fmt" "io" - "path/filepath" "strings" "github.com/jlrickert/cli-toolkit/toolkit" "github.com/jlrickert/tapper/pkg/keg" + "github.com/jlrickert/tapper/pkg/schemas" ) type SchemaOptions struct { KegTargetOptions - Type string - Data []byte + Type string + Data []byte + ExpectedHash string } type EditSchemaOptions struct { KegTargetOptions - Type string - Stream *toolkit.Stream + Type string + Stream *toolkit.Stream + ExpectedHash string } -const schemaDefinitionSchemaModeline = "# yaml-language-server: $schema=" + keg.KegSchemaDefinitionSchemaURL + "\n" - type ValidateOptions struct { KegTargetOptions NodeIDs []string @@ -48,12 +48,26 @@ func (t *Tap) ReadSchema(ctx context.Context, opts SchemaOptions) ([]byte, error return k.ReadSchema(ctx, opts.Type) } +// SchemaHash performs the read half of an explicit CLI read-before-write +// flow. Mutation methods never call it implicitly. +func (t *Tap) SchemaHash(ctx context.Context, opts SchemaOptions) (string, error) { + raw, err := t.ReadSchema(ctx, opts) + if err != nil { + return "", err + } + return keg.DocumentHash(raw), nil +} + +// EditSchema replaces a schema definition. Schemas decide which node types are +// valid and how every write is checked, so defining them is keg administration +// rather than content editing — admin here, matching CreateSchema and +// DeleteSchema. Reading schemas stays viewer. func (t *Tap) EditSchema(ctx context.Context, opts EditSchemaOptions) error { typeName := strings.TrimSpace(opts.Type) if err := keg.ValidSchemaTypeName(typeName); err != nil { return err } - k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleAdmin) if err != nil { return fmt.Errorf("unable to open keg: %w", err) } @@ -61,14 +75,23 @@ func (t *Tap) EditSchema(ctx context.Context, opts EditSchemaOptions) error { if err != nil { return fmt.Errorf("unable to read schema %q: %w", typeName, err) } + expectedHash := opts.ExpectedHash + if (opts.Stream == nil || !opts.Stream.IsPiped) && expectedHash == "" { + expectedHash = keg.DocumentHash(originalRaw) + } + // WriteSchema stores these bytes verbatim, and a schema definition is + // persisted content — shared, on a hub. So the modeline comes off here, + // the same way keg settings drop theirs in Tap.KegSettingsEdit. saveSchema := func(data []byte, source string) error { + data = schemas.StripModeline(data) if err := validateEditedSchema(typeName, data); err != nil { return fmt.Errorf("schema %s is invalid: %w", source, err) } - if err := k.WriteSchema(ctx, typeName, data); err != nil { + if err := k.WriteSchema(ctx, typeName, data, keg.SchemaWriteOptions{ExpectedHash: expectedHash}); err != nil { return fmt.Errorf("unable to save edited schema %q: %w", typeName, err) } + expectedHash = keg.DocumentHash(data) return nil } @@ -78,7 +101,9 @@ func (t *Tap) EditSchema(ctx context.Context, opts EditSchemaOptions) error { return fmt.Errorf("unable to read piped input: %w", readErr) } if len(bytes.TrimSpace(pipedRaw)) > 0 { - if bytes.Equal(pipedRaw, originalRaw) { + // Compare with the modeline stripped: piping back exactly what an + // editor was shown is a no-op, not an edit. + if bytes.Equal(schemas.StripModeline(pipedRaw), originalRaw) { return nil } return saveSchema(pipedRaw, "from stdin") @@ -89,7 +114,9 @@ func (t *Tap) EditSchema(ctx context.Context, opts EditSchemaOptions) error { if err != nil { return fmt.Errorf("unable to create temp schema file path: %w", err) } - initialRaw := ensureYAMLSchemaModeline(originalRaw, schemaDefinitionSchemaModeline) + // Replace rather than ensure: a stored schema may already carry a modeline + // from an older build, and the editor wants the copy this binary shipped. + initialRaw := schemas.ReplaceModeline(originalRaw, schemas.Modeline(t.Runtime, schemas.KegSchemaDefinition)) if err := t.Runtime.WriteFile(tempPath, initialRaw, 0o600); err != nil { return fmt.Errorf("unable to write temp schema file: %w", err) } @@ -98,7 +125,7 @@ func (t *Tap) EditSchema(ctx context.Context, opts EditSchemaOptions) error { }() if err := editWithLiveSaves(ctx, t.Runtime, tempPath, nil, func(editedRaw []byte) error { - if bytes.Equal(editedRaw, originalRaw) { + if bytes.Equal(schemas.StripModeline(editedRaw), originalRaw) { return nil } return saveSchema(editedRaw, "after editing") @@ -118,7 +145,7 @@ func (t *Tap) CreateSchema(ctx context.Context, opts SchemaOptions) error { return fmt.Errorf("schema type is required in schema document: %w", err) } - k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleAdmin) if err != nil { return fmt.Errorf("unable to open keg: %w", err) } @@ -126,11 +153,11 @@ func (t *Tap) CreateSchema(ctx context.Context, opts SchemaOptions) error { } func (t *Tap) DeleteSchema(ctx context.Context, opts SchemaOptions) error { - k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleEditor) + k, err := t.resolveKegForRole(ctx, opts.KegTargetOptions, FlightRoleAdmin) if err != nil { return fmt.Errorf("unable to open keg: %w", err) } - return k.DeleteSchema(ctx, opts.Type) + return k.DeleteSchema(ctx, opts.Type, keg.SchemaWriteOptions{ExpectedHash: opts.ExpectedHash}) } func (t *Tap) Validate(ctx context.Context, opts ValidateOptions) ([]keg.SchemaValidationResult, error) { @@ -246,33 +273,6 @@ func validateEditedSchema(typeName string, data []byte) error { return nil } -func ensureYAMLSchemaModeline(data []byte, modeline string) []byte { - if hasYAMLSchemaModeline(data) { - return data - } - out := make([]byte, 0, len(modeline)+len(data)) - out = append(out, modeline...) - out = append(out, data...) - return out -} - -func hasYAMLSchemaModeline(data []byte) bool { - for _, line := range bytes.Split(data, []byte("\n")) { - trimmed := bytes.TrimSpace(line) - if len(trimmed) == 0 { - continue - } - if bytes.HasPrefix(trimmed, []byte("# yaml-language-server: $schema=")) { - return true - } - if bytes.HasPrefix(trimmed, []byte("#")) { - continue - } - return false - } - return false -} - func schemaEditorTempFilePrefix(k keg.Keg, typeName string) string { namespace, kegName := schemaEditorTempNameParts(k) return fmt.Sprintf("tap-schema-edit-%s-%s-%s-", @@ -283,22 +283,7 @@ func schemaEditorTempFilePrefix(k keg.Keg, typeName string) string { } func schemaEditorTempNameParts(k keg.Keg) (string, string) { - namespace, kegName := logicalKegTempNameParts(k) - if namespace != "local" || kegName != "keg" || k == nil || k.Target() == nil { - return namespace, kegName - } - - file := strings.TrimSpace(k.Target().File) - if file == "" { - return namespace, kegName - } - clean := filepath.Clean(file) - name := strings.TrimSpace(filepath.Base(clean)) - parent := strings.TrimSpace(filepath.Base(filepath.Dir(clean))) - if strings.HasPrefix(parent, "@") && len(parent) > 1 && name != "" && name != "." { - return strings.TrimPrefix(parent, "@"), name - } - return namespace, kegName + return logicalKegTempNameParts(k) } func readAllSchemaInput(r io.Reader) ([]byte, error) { diff --git a/pkg/tapper/tap_schema_test.go b/pkg/tapper/tap_schema_test.go deleted file mode 100644 index de083f91..00000000 --- a/pkg/tapper/tap_schema_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package tapper_test - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -func TestEditSchema_RejectsMissingSchema(t *testing.T) { - t.Parallel() - ctx := context.Background() - tap, _, _ := newSchemaEditFixture(t, ctx) - - err := tap.EditSchema(ctx, tapper.EditSchemaOptions{ - Type: "task", - Stream: pipedSchemaStream("type: task\n"), - }) - require.Error(t, err) - require.ErrorIs(t, err, keg.ErrNotExist) -} - -func TestEditSchema_NoopEditsLeaveContentUnchanged(t *testing.T) { - t.Parallel() - ctx := context.Background() - tap, k, fx := newSchemaEditFixture(t, ctx) - - original := []byte(`type: task -markdown: - requireTitle: true -`) - require.NoError(t, k.WriteSchema(ctx, "task", original)) - - err := tap.EditSchema(ctx, tapper.EditSchemaOptions{ - Type: "task", - Stream: pipedSchemaStream(string(original)), - }) - require.NoError(t, err) - got, err := k.ReadSchema(ctx, "task") - require.NoError(t, err) - require.Equal(t, original, got) - - jail := fx.Runtime().GetJail() - require.NotEmpty(t, jail) - scriptPath := filepath.Join(jail, "schema-noop-editor.sh") - require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 0\n"), 0o755)) - require.NoError(t, fx.Runtime().Set("EDITOR", "/bin/sh "+scriptPath)) - fx.Runtime().Unset("VISUAL") - err = tap.EditSchema(ctx, tapper.EditSchemaOptions{Type: "task"}) - require.NoError(t, err) - got, err = k.ReadSchema(ctx, "task") - require.NoError(t, err) - require.Equal(t, original, got) -} - -func TestEditSchema_EditorStartsWithSchemaModeline(t *testing.T) { - t.Parallel() - ctx := context.Background() - tap, k, fx := newSchemaEditFixture(t, ctx) - - original := []byte(`type: task -markdown: - requireTitle: true -`) - require.NoError(t, k.WriteSchema(ctx, "task", original)) - - jail := fx.Runtime().GetJail() - require.NotEmpty(t, jail) - resolvedJail, err := filepath.EvalSymlinks(jail) - require.NoError(t, err) - require.NoError(t, fx.Runtime().SetJail(resolvedJail)) - jail = resolvedJail - - capturePath := filepath.Join(jail, "captured-schema.yaml") - scriptPath := filepath.Join(jail, "schema-capture-editor.sh") - script := fmt.Sprintf("#!/bin/sh\ncp \"$1\" %q\n", capturePath) - require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) - require.NoError(t, fx.Runtime().Set("EDITOR", "/bin/sh "+scriptPath)) - fx.Runtime().Unset("VISUAL") - - err = tap.EditSchema(ctx, tapper.EditSchemaOptions{Type: "task"}) - require.NoError(t, err) - - raw, err := os.ReadFile(capturePath) - require.NoError(t, err) - opened := string(raw) - require.True(t, strings.HasPrefix(opened, "# yaml-language-server: $schema="+keg.KegSchemaDefinitionSchemaURL+"\n")) - require.Contains(t, opened, "type: task") - - got, err := k.ReadSchema(ctx, "task") - require.NoError(t, err) - require.Equal(t, original, got, "capturing an unchanged editor file must not persist the modeline") -} - -func TestEditSchema_InvalidInputDoesNotOverwrite(t *testing.T) { - t.Parallel() - tests := []struct { - name string - raw string - }{ - { - name: "invalid_yaml", - raw: "type: [\n", - }, - { - name: "type_mismatch", - raw: "type: person\n", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctx := context.Background() - tap, k, _ := newSchemaEditFixture(t, ctx) - original := []byte("type: task\n") - require.NoError(t, k.WriteSchema(ctx, "task", original)) - - err := tap.EditSchema(ctx, tapper.EditSchemaOptions{ - Type: "task", - Stream: pipedSchemaStream(tt.raw), - }) - require.Error(t, err) - - got, readErr := k.ReadSchema(ctx, "task") - require.NoError(t, readErr) - require.Equal(t, original, got) - }) - } -} - -func newSchemaEditFixture(t *testing.T, ctx context.Context) (*tapper.Tap, keg.Keg, *sandbox.Sandbox) { - t.Helper() - fx := NewSandbox(t) - tap, err := tapper.NewTap(tapper.TapOptions{ - Root: "/home/testuser", - Runtime: fx.Runtime(), - }) - require.NoError(t, err) - - k := keg.NewLocalKeg(keg.NewMemoryRepo(fx.Runtime()), fx.Runtime()) - require.NoError(t, k.Init(ctx)) - tap.KegResolver = func(context.Context, tapper.KegTargetOptions, tapper.FlightRole) (keg.Keg, error) { - return k, nil - } - return tap, k, fx -} - -func pipedSchemaStream(raw string) *toolkit.Stream { - return &toolkit.Stream{ - In: strings.NewReader(raw), - IsPiped: true, - } -} diff --git a/pkg/tapper/tap_shadow_reservation_test.go b/pkg/tapper/tap_shadow_reservation_test.go deleted file mode 100644 index 7a4dece6..00000000 --- a/pkg/tapper/tap_shadow_reservation_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package tapper_test - -import ( - "bytes" - "io" - "testing" - - "github.com/jlrickert/cli-toolkit/sandbox" - "github.com/jlrickert/cli-toolkit/toolkit" - "github.com/jlrickert/tapper/pkg/keg" - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// allocateShadowReservation creates a bare node directory via FsRepo.Next() -// without writing content. This models what WithNodeLock or a half-completed -// Create would leave behind. The returned ID has HasNode(true) but -// ReadContent returns ErrNotExist. -// -// It returns the resolved Keg so the caller can hand it to the Tap under test -// — but the bare reservation is visible to any Keg pointed at the same root -// because the filesystem is the source of truth. -func allocateShadowReservation(t *testing.T, fx *sandbox.Sandbox) string { - t.Helper() - k, err := keg.NewKegFromTarget( - fx.Context(), - keg.NewFile("/home/testuser/kegs/@local/test"), - fx.Runtime(), - ) - require.NoError(t, err) - id, err := k.(*keg.LocalKeg).Repo.Next(fx.Context()) - require.NoError(t, err) - - // Sanity: HasNode is true (the bare dir exists), but ReadContent is - // ErrNotExist (no README.md). This is the shape we want to exercise. - has, err := k.(*keg.LocalKeg).Repo.HasNode(fx.Context(), id) - require.NoError(t, err) - require.True(t, has, "shadow reservation should make HasNode true") - _, err = k.(*keg.LocalKeg).Repo.ReadContent(fx.Context(), id) - require.ErrorIs(t, err, keg.ErrNotExist, - "shadow reservation must have no content") - - return id.Path() -} - -// TestMeta_ShadowReservationRejected verifies that Tap.Meta refuses to -// operate on a bare node directory left behind by FsRepo.Next() / -// WithNodeLock. Before the fix this existence gate used Repo.HasNode, -// which returns true for shadow reservations, and the call would proceed -// to return empty metadata as if the node were real. -func TestMeta_ShadowReservationRejected(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - shadowID := allocateShadowReservation(t, fx) - - _, err := tap.Meta(fx.Context(), tapper.MetaOptions{NodeID: shadowID}) - require.Error(t, err, "Meta should reject shadow reservations") - require.Contains(t, err.Error(), "not found", - "error should report the node as missing, not proceed") -} - -// TestEdit_ShadowReservationRejected verifies that Tap.Edit refuses to -// open a bare node directory for editing. The dangerous pre-fix behaviour -// was to pass the existence gate and then attempt to read content from a -// directory with no README.md — leading to an empty-body edit session -// that would create content on a node the user never Created. -func TestEdit_ShadowReservationRejected(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - shadowID := allocateShadowReservation(t, fx) - - stream := &toolkit.Stream{ - In: io.NopCloser(bytes.NewReader([]byte("# Ghost\n"))), - IsPiped: true, - } - err := tap.Edit(fx.Context(), tapper.EditOptions{ - NodeID: shadowID, - Stream: stream, - }) - require.Error(t, err, "Edit should reject shadow reservations") - require.Contains(t, err.Error(), "not found") -} - -// TestUploadFile_ShadowReservationRejected verifies that Tap.UploadFile -// refuses to attach a file to a shadow reservation. The pre-fix bug was -// the most dangerous of the five existence-gate sites: concurrent file -// upload against a bare directory would silently create attachments on a -// contentless node. -func TestUploadFile_ShadowReservationRejected(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - // Stage a source file in the sandbox so UploadFile has something to - // read. The existence gate under test runs before this file is read, - // so we need it staged but the content is immaterial. - src := "/home/testuser/src.txt" - require.NoError(t, fx.Runtime().WriteFile(src, []byte("payload"), 0o644)) - - shadowID := allocateShadowReservation(t, fx) - - _, err := tap.UploadFile(fx.Context(), tapper.UploadFileOptions{ - NodeID: shadowID, - FilePath: src, - }) - require.Error(t, err, "UploadFile should reject shadow reservations") - require.Contains(t, err.Error(), "not found") -} - -// TestUploadImage_ShadowReservationRejected is the image-attachment twin -// of TestUploadFile_ShadowReservationRejected. -func TestUploadImage_ShadowReservationRejected(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - src := "/home/testuser/pic.png" - require.NoError(t, fx.Runtime().WriteFile(src, tinyPNG(t), 0o644)) - - shadowID := allocateShadowReservation(t, fx) - - _, err := tap.UploadImage(fx.Context(), tapper.UploadImageOptions{ - NodeID: shadowID, - FilePath: src, - }) - require.Error(t, err, "UploadImage should reject shadow reservations") - require.Contains(t, err.Error(), "not found") -} - -// TestLock_ShadowReservationRejected verifies that Tap.Lock refuses to -// issue a cross-process lock token against a shadow reservation. The -// pre-fix bug allowed acquiring a lock against a node that did not -// actually exist — an authentication hole against a nonexistent target. -func TestLock_ShadowReservationRejected(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - tap := setupTapWithKeg(t, fx) - - shadowID := allocateShadowReservation(t, fx) - - _, err := tap.Lock(fx.Context(), tapper.LockOptions{NodeID: shadowID}) - require.Error(t, err, "Lock should reject shadow reservations") - require.Contains(t, err.Error(), "not found") -} diff --git a/pkg/tapper/tap_use_test.go b/pkg/tapper/tap_use_test.go deleted file mode 100644 index 126d986a..00000000 --- a/pkg/tapper/tap_use_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package tapper_test - -import ( - "testing" - - "github.com/jlrickert/tapper/pkg/tapper" - "github.com/stretchr/testify/require" -) - -// localUserConfig is what `tap bootstrap --kind local` leaves behind: one local -// hub plus fallbackHub + fallbackKeg, and NO global default/fallback namespace — -// so a bare keg name must infer @local from the hub. -const localUserConfig = "fallbackHub: home\n" + - "fallbackKeg: private\n" + - "namespaces:\n local:\n hub: home\n" + - "hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n" - -// TestNamespaceInference_LocalBareName guards the fix for "namespace is not being -// inferred": a bare keg name (`private`) on a local fallback hub must resolve and -// display as `@local/private` at both the backend (ResolveRef) and the -// display/identity layer (UseStatus → resolveIdentity). -func TestNamespaceInference_LocalBareName(t *testing.T) { - t.Parallel() - fx := NewSandbox(t) - require.NoError(t, fx.Setwd("/home/testuser")) - - tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: fx.Runtime()}) - require.NoError(t, err) - require.NoError(t, fx.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(localUserConfig), 0o644)) - - // Backend resolution: a bare name infers @local on the local hub. - cfg, err := tap.ConfigService.Config() - require.NoError(t, err) - target, err := cfg.ResolveRef(fx.Runtime(), tapper.KegRef{Name: "private"}) - require.NoError(t, err) - require.Contains(t, target.Path(), "@local/private", - "a bare name must infer the @local namespace on a local hub") - - // Display resolution: `tap use` shows the inferred @local/private, not a bare - // name with an empty namespace. - out, err := tap.UseStatus(fx.Context(), tapper.KegTargetOptions{}) - require.NoError(t, err) - require.Contains(t, out, "@local/private") - require.Contains(t, out, "namespace: local") -} diff --git a/pkg/tapper/testhelpers_test.go b/pkg/tapper/testhelpers_test.go index 6943cbac..26515171 100644 --- a/pkg/tapper/testhelpers_test.go +++ b/pkg/tapper/testhelpers_test.go @@ -26,7 +26,7 @@ func NewSandbox(t *testing.T, opts ...sandbox.Option) *sandbox.Sandbox { func makeKegNonStrict(t *testing.T, ctx context.Context, k keg.Keg) { t.Helper() - require.NoError(t, keg.UpdateConfig(ctx, k, func(cfg *keg.Config) { + require.NoError(t, keg.UpdateSettings(ctx, k, func(cfg *keg.Settings) { if cfg.SchemaPolicy == nil { cfg.SchemaPolicy = &keg.SchemaPolicy{} } diff --git a/schemas/embed.go b/schemas/embed.go new file mode 100644 index 00000000..05555ae5 --- /dev/null +++ b/schemas/embed.go @@ -0,0 +1,17 @@ +// Package schemasfs embeds the published JSON Schemas so they ship inside the +// tap binary and can be materialized onto disk at runtime. +// +// The declaration lives here instead of pkg/schemas because the //go:embed +// directive can only reach files rooted at the declaring package's directory, +// and the schemas live at the repository root under schemas/. pkg/schemas +// re-exports FS and owns everything else, so consumers see a single import. +// Same arrangement as integrations/embed.go. +package schemasfs + +import "embed" + +// FS is the embedded tree rooted at the schemas/ directory. Entries are the +// bare file names, e.g. "tap-config.json". +// +//go:embed *.json +var FS embed.FS diff --git a/schemas/flight-manifest.json b/schemas/flight-manifest.json index 4d7c7ee7..edf37a9c 100644 --- a/schemas/flight-manifest.json +++ b/schemas/flight-manifest.json @@ -46,6 +46,17 @@ "additionalProperties": false } }, + "subflights": { + "type": "array", + "description": "Ordered child relations in a bounded recursive graph. Each selected descendant keeps independent instructions and authority.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?:@[a-z0-9][a-z0-9-]{0,63}/)?\\+[a-z0-9][a-z0-9-]{0,63}$" + }, + "uniqueItems": true, + "maxItems": 64 + }, "allowedKegs": { "type": "array", "description": "Legacy local-manifest cover list. Bare entries are treated as editor cover rows for backward compatibility.", diff --git a/schemas/keg-config.json b/schemas/keg-settings.json similarity index 96% rename from schemas/keg-config.json rename to schemas/keg-settings.json index 02f4af9c..99d18229 100644 --- a/schemas/keg-config.json +++ b/schemas/keg-settings.json @@ -1,13 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/keg-config.json", - "title": "tapper keg config", - "description": "Schema for keg metadata stored in a keg repository config file.", + "$id": "https://raw.githubusercontent.com/jlrickert/tapper/main/schemas/keg-settings.json", + "title": "tapper keg settings", + "description": "Schema for keg metadata stored in a keg settings document.", "type": "object", "properties": { "kegv": { "type": "string", - "description": "Configuration schema version.", + "description": "Settings schema version.", "enum": ["2025-07"] }, "updated": { diff --git a/schemas/tap-config.json b/schemas/tap-config.json index 045d652d..1c791e25 100644 --- a/schemas/tap-config.json +++ b/schemas/tap-config.json @@ -7,7 +7,7 @@ "properties": { "defaultKeg": { "type": "string", - "description": "Keg reference resolved when no explicit keg is requested: a bare name, @namespace/name, keg:@namespace/name, or a filesystem path. Resolved through the namespace-centric chain. Override with TAP_DEFAULT_KEG env var." + "description": "Remote keg reference resolved when no explicit keg is requested: a bare name, @namespace/name, or keg:@namespace/name. Resolved through the namespace-centric chain. Override with TAP_DEFAULT_KEG env var." }, "fallbackKeg": { "type": "string", @@ -17,6 +17,10 @@ "type": "string", "description": "Flight context applied when no --flight flag is given: @namespace/+slug, +slug, or a bare slug. Conventionally set in project config so orient/MCP inherit the same context. Override with TAP_FLIGHT env var." }, + "agent": { + "type": "string", + "description": "Name of the entry in agents{} driving this process. `tap launch` uses it as the default when --agent is omitted, the same way flight supplies the launch root, and exports the resolved name as TAP_AGENT so the child can report its own identity. Selects a model and supplies telemetry only — flight selection is independent. Override with TAP_AGENT env var. Experimental: expected to change when agents move to the hub." + }, "defaultHub": { "type": "string", "description": "High-precedence hub name used when a keg reference omits its hub. The authoritative choice; set it in project config. Override with TAP_DEFAULT_HUB env var." @@ -37,10 +41,6 @@ "type": "boolean", "description": "When true, the synthesized built-in atlas hub (https://atlas.foldwise.ai) is suppressed: it is not synthesized, is omitted from hub listings, and is skipped in hub resolution, so hub-dependent commands fail with a clear error if no other hub is configured. An explicit atlas entry in hubs{} is unaffected. Override with TAP_DISABLE_ATLAS_HUB env var (1/true/yes/on)." }, - "disableLocalHub": { - "type": "boolean", - "description": "When true, the synthesized built-in local filesystem hub is suppressed (symmetric with disableAtlasHub). An explicit local hub entry in hubs{} is unaffected. Override with TAP_DISABLE_LOCAL_HUB env var (1/true/yes/on)." - }, "disableTelemetry": { "type": "boolean", "description": "When true, disables privacy-minimized Tap CLI and MCP invocation reporting. Reporting is enabled by default and can also be disabled with TAP_DISABLE_TELEMETRY=1." @@ -57,7 +57,7 @@ "description": "Hub name that hosts this namespace. Empty falls back to the hub precedence chain (defaultHub → fallbackHub → sole hub → compiled-in default)." } }, - "additionalProperties": false + "additionalProperties": true } }, "kegMap": { @@ -81,7 +81,7 @@ } }, "required": ["alias"], - "additionalProperties": false + "additionalProperties": true } }, "hubs": { @@ -93,21 +93,17 @@ "properties": { "kind": { "type": "string", - "enum": ["remote", "local", "readonly"], - "description": "Hub backend kind: 'remote' (read-write HTTP, the default when omitted), 'local' (filesystem on this machine), or 'readonly' (read-only HTTP)." + "enum": ["remote", "readonly"], + "description": "Hub backend kind: 'remote' (read-write HTTP, the default when omitted) or 'readonly' (read-only HTTP)." }, "defaultNamespace": { "type": "string", - "description": "Default namespace for kegs resolved against this hub when the reference omits its namespace. A hub hosts many namespaces; this is only the default. The @ sigil is implied; do not include it. For the local hub this is 'local' (addressed as @local). In the namespace-centric model this is a LOWER-precedence source than the top-level defaultNamespace/fallbackNamespace — it applies only on hub-first paths after those chains yield nothing." + "description": "Default namespace for kegs resolved against this hub when the reference omits its namespace. A hub hosts many namespaces; this is only the default. The @ sigil is implied; do not include it. In the namespace-centric model this is a LOWER-precedence source than the top-level defaultNamespace/fallbackNamespace — it applies only on hub-first paths after those chains yield nothing." }, "url": { "type": "string", "description": "Base URL for remote/readonly hubs." }, - "basePath": { - "type": "string", - "description": "Filesystem root for local hubs. Kegs resolve to /@/." - }, "token": { "type": "string", "description": "Inline token credential for hub access." @@ -117,7 +113,46 @@ "description": "Environment variable name containing the hub token." } }, - "additionalProperties": false + "additionalProperties": true + } + }, + "agents": { + "type": "object", + "description": "Named model definitions for `tap launch`, keyed by alias. Agents hold no secrets — apiKeyEnv names a variable, never a value — so unlike hubs they survive the project-config trust strip and may safely be defined in project config. Experimental: expected to change when agents move to the hub.", + "additionalProperties": { + "type": "object", + "description": "Single agent definition: a model plus how to reach and authenticate against it.", + "properties": { + "model": { + "type": "string", + "description": "Provider-qualified model, '/' — anthropic/claude-opus-4, openai/gpt-5, ollama/qwen3.6:35b. The prefix tells the launcher which protocol the harness must speak; recognized providers are anthropic, openai, and ollama." + }, + "baseUrl": { + "type": "string", + "description": "Overrides the provider's endpoint. One value serves both protocols: the launcher appends /v1 for OpenAI clients and trims it for Anthropic ones. Defaults to http://localhost:11434/v1 for ollama models; empty for hosted providers, leaving the harness on its own endpoint." + }, + "auth": { + "type": "string", + "enum": ["inherit", "subscription", "apiKey", "none"], + "description": "Where the harness gets credentials. 'inherit' passes the ambient environment through untouched. 'subscription' strips inherited provider key variables so the harness falls back to its own stored login. 'apiKey' forwards the variable named by apiKeyEnv. 'none' strips the same variables but implies no stored login to fall back to. When omitted the mode is derived: apiKey if apiKeyEnv is set, else none for ollama models, else inherit." + }, + "apiKeyEnv": { + "type": "string", + "description": "Name of the environment variable holding the API key — the name is configured, never the secret, mirroring hubs.*.tokenEnv. Setting it also selects auth 'apiKey' when auth is omitted." + }, + "contextWindow": { + "type": "integer", + "minimum": 1, + "description": "Caps the working context in tokens. Harnesses express this differently — Codex as model metadata, Claude Code as an auto-compact threshold — so the launcher translates it per harness, and launching one with no equivalent is an error rather than a silent drop." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Extra arguments passed to the harness. Placed before any arguments given after `--` on the tap launch command line, so a one-off invocation can override them." + } + }, + "required": ["model"], + "additionalProperties": true } }, "logFile": { diff --git a/test-env/README.md b/test-env/README.md index 55fa0ea3..e152595a 100644 --- a/test-env/README.md +++ b/test-env/README.md @@ -6,11 +6,10 @@ An isolated Ubuntu 24.04 container for testing tapper as a real user would encounter it. The tapper source is bind-mounted **read-only** at `/usr/local/src/tapper` purely as a build input -- the interactive shell lands in the user's home (`/home/jlrickert`) with no project context, so -`tap init` and friends behave the same as on a vanilla machine. +remote bootstrap and Hub workflows behave the same as on a vanilla machine. -Go module and build caches live in named volumes for speed; user-created -kegs live in the container's writable layer so they survive `restart` and -shell re-entry but are wiped on `rebuild` / `clean`. +Go module and build caches live in named volumes for speed. KEG data remains on +the configured remote Hub. ## Prerequisites @@ -38,9 +37,8 @@ task sandbox:shell # drop into an interactive zsh login shell | `task sandbox:exec` | Run an arbitrary command (`task sandbox:exec -- ls -la`). | | `task sandbox:logs` | Follow container logs. | | `task sandbox:status` | Show container state. | -| `task sandbox:rebuild-tap` | Reinstall `tap` and `keg` from the bind-mounted source. | +| `task sandbox:rebuild-tap` | Reinstall `tap` from the bind-mounted source. | | `task sandbox:refresh-dotfiles` | Rebuild image against live dotfiles HEAD; recreate. | -| `task sandbox:populate` | Seed the sandbox with a fixture keg (`-- `). | | `task sandbox:test` | Run `go test ./...` inside the container. | | `task sandbox:clean` | Remove container AND named volumes (nukes caches). | @@ -59,7 +57,7 @@ container. | `task sandbox:shell-work` | Interactive zsh login shell in the work-mode container. | | `task sandbox:exec-work` | Run an arbitrary command in the work-mode container (`-- ...`). | | `task sandbox:test-work` | `go test ./...` against the local cli-toolkit. | -| `task sandbox:rebuild-tap-work` | Reinstall `tap`/`keg` linking the local cli-toolkit. | +| `task sandbox:rebuild-tap-work` | Reinstall `tap` linking the local cli-toolkit. | | `task sandbox:down-work` | Stop and remove the work-mode container (keeps named volumes). | Prerequisites: @@ -68,7 +66,7 @@ Prerequisites: (i.e., a sibling of the tapper repo). Override with the `CLI_TOOLKIT_PATH` host environment variable if it lives elsewhere. - The work-mode container is **separate** from the default `sandbox` and - has its own first-boot install of `tap`/`keg`. Named caches (Go module, + has its own first-boot install of `tap`. Named caches (Go module, Go build, tapper state) are shared with `sandbox`. ## Inside the container @@ -79,12 +77,8 @@ Prerequisites: - `GOPATH=/home/jlrickert/go`, `GOCACHE=/home/jlrickert/.cache/go-build` (both backed by named volumes). - Tapper state: `~/.local/state/tapper` (named volume). -- User-created kegs default to `~/.local/share/tapper/kegs//` (in - the container's writable layer; persists through `restart`, wiped on - `rebuild`). -- `tap` and `keg` are on `$PATH` after the first boot, with zsh tab - completion for both registered automatically (entrypoint drops - Cobra-generated `_tap` and `_keg` files into +- `tap` is on `$PATH` after the first boot, with zsh tab completion registered + automatically (the entrypoint drops a Cobra-generated `_tap` file into `/usr/local/share/zsh/site-functions/`, which is in zsh's default fpath). The dir is `chown`d to `jlrickert` in the Dockerfile so the unprivileged user can write there. @@ -94,25 +88,6 @@ Prerequisites: packages, edit the `dots install` line in `test-env/Dockerfile` and `task sandbox:rebuild`. -## Fixtures - -`test-env/fixtures/` holds named keg trees that can be loaded into a -running sandbox: - -```sh -task sandbox:populate -- --list # show available fixtures -task sandbox:populate -- minimal # copy 'minimal' into ~/.local/share/tapper/kegs/ -task sandbox:populate -- --all # copy every fixture -``` - -The first populate also writes a minimal `~/.config/tapper/config.yaml` -with the fixture root in `kegSearchPaths` so `tap list-kegs` discovers -them. If you've already run `tap init`, the script leaves your config -alone and prints a hint. - -To add a fixture, create `test-env/fixtures//` with a `keg` config -file and a `0/README.md`. See `test-env/fixtures/README.md`. - ## Updating dotfiles The Dockerfile pins the dotfiles checkout via `ARG DOTFILES_REV=` so @@ -131,7 +106,7 @@ manually when a newer release lands. ## Caveats - First `task sandbox:up` is slow: downloads Ubuntu base, installs Go, runs - `dots init`, and performs the initial `go install ./cmd/tap ./cmd/keg`. + `dots init`, and performs the initial `go install ./cmd/tap`. - Host edits appear live under `/usr/local/src/tapper`, but rebuilt binaries only land on `$PATH` after `task sandbox:rebuild-tap`. - `task sandbox:clean` destroys the Go module cache, build cache, and tapper diff --git a/test-env/Taskfile.yml b/test-env/Taskfile.yml index 87d1c763..751389cb 100644 --- a/test-env/Taskfile.yml +++ b/test-env/Taskfile.yml @@ -61,15 +61,14 @@ tasks: - "{{.COMPOSE}} ps" rebuild-tap: - desc: Rebuild tap and keg binaries inside the sandbox from current source. + desc: Rebuild tap inside the sandbox from current source. deps: [up] cmds: - | podman exec -i tapper-sandbox bash -lc ' set -e - cd /usr/local/src/tapper && go install ./cmd/tap ./cmd/keg + cd /usr/local/src/tapper && go install ./cmd/tap tap completion zsh > /usr/local/share/zsh/site-functions/_tap - keg completion zsh > /usr/local/share/zsh/site-functions/_keg ' refresh-dotfiles: @@ -84,17 +83,6 @@ tasks: - "{{.COMPOSE}} build --build-arg DOTFILES_REV={{.DOTFILES_HEAD}} sandbox" - "{{.COMPOSE}} up -d --force-recreate sandbox" - populate: - desc: | - Seed the sandbox with a keg fixture from test-env/fixtures/. - Examples: - task sandbox:populate -- --list - task sandbox:populate -- minimal - task sandbox:populate -- --all - deps: [up] - cmds: - - podman exec -i tapper-sandbox bash /usr/local/src/tapper/test-env/scripts/populate.sh {{.CLI_ARGS}} - test: desc: Run the Go test suite inside the sandbox. deps: [up] @@ -143,15 +131,14 @@ tasks: - podman exec -i tapper-sandbox-work bash -lc 'cd /usr/local/src/tapper && go test ./...' rebuild-tap-work: - desc: Rebuild tap and keg in work mode (links local cli-toolkit). + desc: Rebuild tap in work mode (links local cli-toolkit). deps: [up-work] cmds: - | podman exec -i tapper-sandbox-work bash -lc ' set -e - cd /usr/local/src/tapper && go install ./cmd/tap ./cmd/keg + cd /usr/local/src/tapper && go install ./cmd/tap tap completion zsh > /usr/local/share/zsh/site-functions/_tap - keg completion zsh > /usr/local/share/zsh/site-functions/_keg ' _ensure-go-work: diff --git a/test-env/entrypoint.sh b/test-env/entrypoint.sh index 3b040023..8785132a 100755 --- a/test-env/entrypoint.sh +++ b/test-env/entrypoint.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Idempotent startup for the tapper sandbox container. On first boot, build -# tap/keg from the bind-mounted source. Subsequent boots skip the install and +# tap from the bind-mounted source. Subsequent boots skip the install and # just exec the CMD. set -euo pipefail @@ -18,13 +18,13 @@ REPO="/usr/local/src/tapper" export GOWORK="${GOWORK-off}" if [[ ! -f "${SENTINEL}" && -d "${REPO}" ]]; then - echo "[sandbox] First boot: installing tap and keg from ${REPO}..." + echo "[sandbox] First boot: installing tap from ${REPO}..." # -buildvcs=false: source is bind-mounted ro and on macOS the .git # directory's host uid doesn't match the in-container jlrickert, # so Go's VCS stamping fails with exit 128 and crashes the # entrypoint into a restart loop. Sandbox binaries don't need # VCS stamps anyway. - (cd "${REPO}" && go install -buildvcs=false ./cmd/tap ./cmd/keg) + (cd "${REPO}" && go install -buildvcs=false ./cmd/tap) # Drop Cobra-generated completion files into the system zsh site-functions # dir (already in default fpath, chown'd to jlrickert in the Dockerfile). @@ -32,10 +32,9 @@ if [[ ! -f "${SENTINEL}" && -d "${REPO}" ]]; then # updates after first boot. COMPDIR="/usr/local/share/zsh/site-functions" "${HOME}/go/bin/tap" completion zsh > "${COMPDIR}/_tap" - "${HOME}/go/bin/keg" completion zsh > "${COMPDIR}/_keg" touch "${SENTINEL}" - echo "[sandbox] Ready. tap and keg are on PATH; completion installed." + echo "[sandbox] Ready. tap is on PATH; completion installed." fi exec "$@" diff --git a/test-env/fixtures/README.md b/test-env/fixtures/README.md deleted file mode 100644 index 2b17c14a..00000000 --- a/test-env/fixtures/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Sandbox keg fixtures - -Each subdirectory here is a complete keg tree that can be copied into a -running sandbox via `task sandbox:populate -- `. Fixtures land at -`~/.local/share/tapper/kegs//` inside the container. - -## Adding a fixture - -1. Create a directory under `test-env/fixtures//`. -2. Populate it with a valid keg tree: - - `keg` config file (kegv: 2023-01) - - `0/README.md` for the zero node - - additional numbered nodes as desired -3. Run `task sandbox:populate -- --list` to confirm it appears. - -The fixture format mirrors the on-disk keg layout. Tapper auto-derives -`meta.yaml` and `stats.json` for nodes that don't include them, so a -hand-authored fixture only strictly needs the `keg` file plus -`/README.md` per node. diff --git a/test-env/fixtures/minimal/keg b/test-env/fixtures/minimal/keg deleted file mode 100644 index 574a9060..00000000 --- a/test-env/fixtures/minimal/keg +++ /dev/null @@ -1,14 +0,0 @@ -updated: 2026-04-29 00:00:00Z -kegv: 2023-01 -title: Minimal sandbox fixture -creator: tapper sandbox -state: living -summary: | - A minimal keg fixture with a single zero node. Useful for exercising - list / cat / orient / doctor against a non-empty keg without the noise - of a fully populated example. -indexes: - - file: dex/changes.md - summary: latest changes - - file: dex/nodes.tsv - summary: all nodes by id diff --git a/test-env/scripts/populate.sh b/test-env/scripts/populate.sh deleted file mode 100755 index bfc09aa4..00000000 --- a/test-env/scripts/populate.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# Seed the sandbox with a keg fixture from test-env/fixtures/. -# -# Fixtures are copied into ~/.local/share/tapper/kegs// inside the -# sandbox container. A minimal user config at ~/.config/tapper/config.yaml -# is created if absent so kegSearchPaths includes the fixture root and -# `tap list-kegs` discovers them. - -set -euo pipefail - -FIXTURE_DIR=/usr/local/src/tapper/test-env/fixtures -KEG_ROOT="${HOME}/.local/share/tapper/kegs" -CFG_DIR="${HOME}/.config/tapper" -CFG_FILE="${CFG_DIR}/config.yaml" - -list_fixtures() { - if [[ ! -d "${FIXTURE_DIR}" ]]; then - echo "(no fixtures directory at ${FIXTURE_DIR})" - return - fi - local found=0 - for f in "${FIXTURE_DIR}"/*/; do - [[ -d "${f}" ]] || continue - echo " $(basename "${f}")" - found=1 - done - if [[ "${found}" -eq 0 ]]; then - echo " (none)" - fi -} - -usage() { - cat < - -Available fixtures: -$(list_fixtures) -USAGE -} - -# A user that ran 'tap init' has their own config; appending may produce a -# duplicate kegSearchPaths key. Only seed config when absent. If present, -# emit a one-line hint about the path the user can add manually. -ensure_config() { - if [[ -f "${CFG_FILE}" ]]; then - if ! grep -q "share/tapper/kegs" "${CFG_FILE}"; then - echo "[hint] ${CFG_FILE} exists; add '${KEG_ROOT}' to kegSearchPaths to discover fixtures." >&2 - fi - return - fi - mkdir -p "${CFG_DIR}" - cat > "${CFG_FILE}" <&2 - echo "Available:" >&2 - list_fixtures >&2 - return 1 - fi - if [[ -d "${dst}" ]]; then - echo "[skip] ${name} already at ${dst}" - return 0 - fi - mkdir -p "${KEG_ROOT}" - cp -r "${src}" "${dst}" - chmod -R u+w "${dst}" - echo "[ok] ${name} -> ${dst}" -} - -populate_all() { - if [[ ! -d "${FIXTURE_DIR}" ]]; then - echo "No fixtures directory at ${FIXTURE_DIR}" >&2 - return 1 - fi - local any=0 - for f in "${FIXTURE_DIR}"/*/; do - [[ -d "${f}" ]] || continue - populate_one "$(basename "${f}")" || true - any=1 - done - if [[ "${any}" -eq 0 ]]; then - echo "No fixtures to populate." - fi -} - -case "${1:-}" in - "" | -h | --help | --list) - usage - ;; - --all) - ensure_config - populate_all - ;; - *) - ensure_config - populate_one "$1" - ;; -esac diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index bfa0fead..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} From 6b911ebc3cd62e399839050b86381662a3a6d0a0 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Sun, 30 Aug 2026 19:24:52 -0500 Subject: [PATCH 2/2] docs: describe the remote-only surface Bring the prose in line with the single-backend architecture: `tap` is the only CLI, kegs resolve through configured hubs rather than disk discovery, `LocalKeg` runs over the Hub's PgRepo, and the keg document is called settings. Replaces the keg-config configuration page with keg-settings and drops the graph visualization references. CHANGELOG.md keeps its hand-written Unreleased notes for now; the release workflow regenerates the file from Conventional Commit subjects, so this prose is reference material rather than the published notes. --- AGENTS.md | 18 ++ CHANGELOG.md | 66 +++++- CLAUDE.md | 163 +++++++------- README.md | 35 ++- docs/README.md | 13 +- docs/ai-coding-agents/README.md | 2 +- docs/ai-coding-agents/agent-conventions.md | 41 ++-- docs/ai-coding-agents/claude-code-plugin.md | 7 +- docs/ai-coding-agents/codex.md | 23 +- docs/ai-coding-agents/launchers.md | 28 ++- docs/ai-coding-agents/mcp-setup.md | 104 +++++---- docs/ai-coding-agents/orient.md | 83 +++++-- docs/architecture/README.md | 8 +- docs/architecture/cli-and-command-flow.md | 40 +--- docs/architecture/repository-layer.md | 69 ++---- docs/architecture/service-layer.md | 53 +++-- docs/architecture/testing-architecture.md | 68 ++---- docs/backups-and-archives.md | 2 +- docs/configuration/README.md | 13 +- docs/configuration/examples.md | 91 ++++---- docs/configuration/flights.md | 202 +++++++++--------- .../{keg-config.md => keg-settings.md} | 6 +- docs/configuration/project-config.md | 4 +- docs/configuration/resolution-order.md | 57 +++-- docs/configuration/troubleshooting.md | 12 +- docs/configuration/user-config.md | 103 ++++----- .../domain-separation-and-migration.md | 4 +- docs/keg-structure/example-structures.md | 31 +-- docs/keg-structure/markdown-style-guide.md | 9 +- docs/keg-structure/minimum-node.md | 60 ++---- docs/node-snapshots.md | 49 ++--- 31 files changed, 693 insertions(+), 771 deletions(-) rename docs/configuration/{keg-config.md => keg-settings.md} (94%) diff --git a/AGENTS.md b/AGENTS.md index ccfca160..3f662d27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,24 @@ See [CLAUDE.md](./CLAUDE.md) for comprehensive project documentation including architecture, build commands, testing, and contribution guidelines. +## Coordinated immutable-flight delivery gate + +The immutable-flight direct-subflight work is a coordinated change with the sibling +Tapper Hub repository. Until the user explicitly approves delivery after joint +verification: + +- Do not merge related Tapper or Tapper Hub changes. +- Do not create or push release tags, GitHub releases, release commits, or + trigger release workflows. +- Do not permanently update Tapper Hub's Tapper dependency pin. +- Use Tapper Hub's local `go.work` link for cross-repository integration + testing. +- Stop at reviewable local branches/commits, test evidence, and a coordination + report. Create or update PRs only when the user requests it. + +These restrictions remain in force even when tests pass or either repository +appears independently ready to ship. + Commit messages should follow Conventional Commits. Do not use Conventional Commits breaking-change syntax (`!` after the type or diff --git a/CHANGELOG.md b/CHANGELOG.md index f493f1f6..db71bf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ All notable changes to this project are documented in this file. +## Unreleased + +### 💥 Breaking Changes + +- **cli:** make Tapper remote-only: remove filesystem hubs and targets, local + creation flags, the standalone `keg` binary, and local flight manifests; + cloud and enterprise HTTP(S) Hubs are the supported bootstrap destinations +- **mcp:** replace session-wide direct-subflight orientation with live + per-call `flight` selection across the pinned root's recursive accessible + graph; remove the unreleased `orient.subflight` input and `repo_init` tool +- **mcp:** remove `keg_list.all`; omission now discovers the live pinned-root + graph and an explicit `flight` discovers exactly that flight +- **mcp:** make `orient` and `tapper://orient` read-only, pin local roots from + the normal configuration cascade, and reserve `session_refresh` for + failed-root recovery + +### 🚀 Features + +- **cli:** allow `tap launch` without a configured flight. The harness starts + under no-flight identity authority and the launcher warns which access is in + play, so a fresh account can launch an agent to create its first flight and + KEGs instead of needing a flight in order to make one +- **mcp:** require read-derived hashes in every protected mutation schema and + carry distinct hashes for each node in atomic removal batches +- **mcp:** remove the legacy archive import tool while retaining cross-keg + imports and CLI archive workflows +- **keg:** require optimistic-concurrency tokens for settings, schemas, node + updates, moves, and removals with actionable conflict recovery content +- **mcp:** expose breadth-first available flights and selected paths, isolate + concurrent descendant calls, and adopt authority changes on the next call +- **mcp:** add live selected-flight and pinned-root-graph KEG discovery with + deterministic effective-role and granting-flight provenance through + `keg_list` +- **mcp:** add ungoverned `keg_search` for bounded literal search across all + identity-accessible KEG metadata without widening flight authority +- **mcp:** report deterministic session-refresh statuses, preserve state on + failed refresh, and notify clients only when the visible tool allowlist changes + +### 🐛 Bug Fixes + +- **keg:** align remote visibility, rename, and index-rebuild requests with the + Hub's consolidated `/access`, `/rename`, and `/indexes/rebuild` routes +- **mcp:** resolve configured Hub aliases to canonical authenticated URLs so a + coverless remote root enters active mode and publishes the complete surface +- **flights:** validate the completed acyclic graph's longest root-to-descendant + path so a shared descendant first found by a shorter breadth-first path cannot + bypass the depth-eight contract +- **keg:** export one canonical explicit Markdown H1 helper for create surfaces + while preserving ordinary content-title fallback behavior elsewhere +- **settings:** preserve unknown YAML fields and comments by overlaying + Tapper-owned mutations onto parsed configuration and KEG documents + +### 🚜 Refactor + +- **keg:** retain `LocalKeg` as Hub-side orchestration while routing every + production Tapper operation through `RemoteKeg` and Hub-compatible HTTP APIs +- **test:** move the concurrency-safe repository double under + `internal/testkegrepo`, keep it out of production dependency graphs, and use + PostgreSQL integration tests for durable storage behavior +- **keg:** consolidate remote create, update, remove, and snapshot batches on + the canonical node routes and replace import redirects through `UpdateNodes` +- **keg:** rename the keg-specific config API, schema, and remote resource to + settings while preserving extension fields in the remote YAML document + + ## v0.38.0 - 2026-08-22 @@ -888,4 +953,3 @@ All notable changes to this project are documented in this file. - add comprehensive lock integration and concurrency tests - add comprehensive tests for tap site command - add benchmark tests for tap site serve handlers - diff --git a/CLAUDE.md b/CLAUDE.md index fc3ba699..9976eb5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Coordinated immutable-flight delivery gate + +The immutable-flight direct-subflight work is a coordinated change with the sibling +Tapper Hub repository. Until the user explicitly approves delivery after joint +verification: + +- Do not merge related Tapper or Tapper Hub changes. +- Do not create or push release tags, GitHub releases, release commits, or + trigger release workflows. +- Do not permanently update Tapper Hub's Tapper dependency pin. +- Use Tapper Hub's local `go.work` link for cross-repository integration + testing. +- Stop at reviewable local branches/commits, test evidence, and a coordination + report. Create or update PRs only when the user requests it. + +These restrictions remain in force even when tests pass or either repository +appears independently ready to ship. + ## Overview **tapper** is a Go CLI toolset for managing KEGs (Knowledge Exchange Graphs). A @@ -10,18 +28,14 @@ KEG is a repository of numbered nodes, each containing README.md (content), meta.yaml (metadata), and stats.json (programmatic stats). The system supports indexing, tagging, linking between nodes, and snapshot-based revision history. -Two CLI entrypoints share the same Cobra command tree: - -- `tap` — full CLI surface with multi-keg support and user/project config - resolution -- `keg` — pruned profile with project-local defaults +The `tap` CLI provides the full multi-KEG surface with user/project config +resolution. ## Build & Development Commands ```bash # Build go build ./cmd/tap -go build ./cmd/keg # Test go test ./... # all tests @@ -30,8 +44,7 @@ go test ./pkg/keg -run TestConcurrentCreate # single test by name go test -race ./pkg/keg/... # with race detector # Install (requires go-task) -task install-tap # install tap + zsh completions -task install-keg # install keg + zsh completions +task install-tap # install tap task test # cached test run of ./pkg/... # Lint @@ -82,9 +95,9 @@ a baseline or suppression list and runs explicitly in CI. delegate to `pkg/keg`. - **`pkg/cli/`** — Cobra command definitions bridging CLI flags to `pkg/tapper` and `pkg/keg`. -- **`pkg/keg_url/`** — Target URL parsing (file://, memory://, API schemes) and - expansion. -- **`pkg/mcp/`** — MCP server: 46 tools exposing the full Tap surface over +- **`pkg/keg/target.go`** — Target parsing for HTTP(S) and keg-reference + schemes. +- **`pkg/mcp/`** — MCP server exposing the agent-safe Tap surface over stdio JSON-RPC, wired by 19 `register*Tools()` functions in `server.go`. See `docs/ai-coding-agents/mcp-setup.md`. @@ -96,7 +109,7 @@ orchestration internally (locking discipline, dex/index maintenance, stats touching). Two implementations exist: - `*keg.LocalKeg` (`keg.go` + `keg_local_*.go`) orchestrates a `Repository` - (`FsRepo`, `MemoryRepo`, or the hub's server-side `PgRepo`) and maintains + (the Hub's server-side `PgRepo`, or the test-only memory repository) and maintains derived state itself. - `*keg.RemoteKeg` (`keg_remote.go`) speaks tapper-hub's operation-level HTTP API — one request per operation; all orchestration happens server-side. @@ -110,8 +123,8 @@ CLI command → pkg/cli (Cobra) → pkg/tapper.Tap → keg.Keg → storage MCP tool call → pkg/mcp (JSON-RPC) → pkg/tapper.Tap → keg.Keg → storage ``` -where `keg.Keg` is a `LocalKeg` over `FsRepo`/`MemoryRepo` for local kegs, or -a `RemoteKeg` over the hub's operation API for remote kegs. +where Tapper clients always use `RemoteKeg`; Tapper Hub uses `LocalKeg` over +its PostgreSQL repository. Both paths converge at `pkg/tapper.Tap`, sharing the same method and `*Options` struct for each feature. The CLI path uses `applyKegTargetProfile()` to resolve @@ -120,15 +133,10 @@ Cobra flags into options and writes results to stdout. The MCP path uses returns `CallToolResult` values. Server wiring in `NewServer()` calls 19 `register*Tools()` functions to expose the full Tap surface over stdio JSON-RPC. -**Repository** (`pkg/keg/repository.go`) is the **local-only** storage -contract — remote kegs do not go through it (RemoteKeg talks to the hub's -operation API instead). Two implementations live in this repo: - -- `MemoryRepo` (`repo_memory.go`) — in-memory, used in tests -- `FsRepo` (`repo_filesystem.go`) — filesystem-backed, numbered directories - -(tapper-hub provides a third, Postgres-backed `PgRepo`, driven by a -server-side `LocalKeg`.) +**Repository** (`pkg/keg/repository.go`) is the Hub-side storage contract — +`RemoteKeg` talks to the Hub's operation API instead. Tapper Hub provides the +production PostgreSQL `PgRepo`; Tapper's concurrency-safe in-memory repository +exists only in `_test.go` for repository-independent `LocalKeg` tests. **Dex** (`pkg/keg/dex.go`) is the in-memory index aggregator. It holds NodeIndex, TagIndex, LinkIndex, BacklinkIndex, and ChangesIndex. Written as @@ -139,32 +147,18 @@ config precedence: explicit `--keg` reference → `defaultKeg` → `kegMap` path match → `fallbackKeg`, each a keg reference resolved through `ResolveRef`. The `default*` slots are authoritative (project config sets them) and win over a `kegMap` path rule; `fallback*` is the global-user last resort that `tap -bootstrap` writes, so anything more specific overrides it. A bare name that -resolves to nothing falls back to a project-local `./kegs/` keg. Active +bootstrap` writes, so anything more specific overrides it. A bare name with no +resolvable remote namespace and Hub is an error. Active flight cover caps are enforced for the MCP surface; direct CLI commands keep normal keg authorization and preserve `--flight` only as context for orient and MCP defaults (`Tap.resolveKeg`). ### Storage Model -``` -/ - keg # KEG config (YAML, versioned with kegv field) - 0/ # Zero node (always present after init) - README.md # Content (markdown) - meta.yaml # User-facing metadata (tags, links, title) - stats.json # Programmatic stats (hash, timestamps, access count) - 1/ - README.md - meta.yaml - stats.json - dex/ # Generated indices - nodes.tsv # ID → timestamp → title - tags # tag → node IDs - links # source → destinations - backlinks # destination → sources - changes.md # Reverse-chronological changelog -``` +Tapper clients have no KEG storage layout. Every operation targets +`/api/v1/@/kegs/`. Tapper Hub persists settings, +nodes, metadata, indexes, snapshots, and attachments in PostgreSQL through +`PgRepo`; server-side `LocalKeg` owns orchestration. ### Config Hierarchy @@ -187,15 +181,14 @@ a hard error). The merged project layer, the user config, and env vars are then resolved by `cfgcascade.Cascade[*Config]` in `ConfigService.Config()`. **Hub / namespace resolution** (`Config.ResolveRef`) is namespace-centric: -**keg name → namespace → hub → backend**. There is no `kegs` alias map — a keg +**keg name → namespace → Hub**. There is no `kegs` alias map — a keg selector (`defaultKeg`, `fallbackKeg`, `--keg`, a `kegMap` alias) is parsed as a keg reference by `parseKegRef` and resolved directly. The namespace resolves -first (explicit → `defaultNamespace` → `fallbackNamespace` → per-hub default / -`@local` / error), then the hub is resolved *from* the namespace (explicit → -`namespaces[ns].hub` → `@local`→local hub → `defaultHub` → `fallbackHub` → +first (explicit → `defaultNamespace` → `fallbackNamespace` → per-Hub default / +error), then the Hub is resolved *from* the namespace (explicit → +`namespaces[ns].hub` → `defaultHub` → `fallbackHub` → sole/alpha hub → compiled-in `atlas`). The `namespaces` map disambiguates -namespace→hub. The local hub is keyed by hostname (via `tap bootstrap`), uses -the reserved `@local` namespace, and stores kegs at `/@/`. +namespace→Hub. `@local` has no special meaning. A keg reference renders as the `keg` scheme — `keg:@/` (and `keg:@//` for a node). The hub is resolution metadata, @@ -204,17 +197,15 @@ hub explicitly, set `defaultHub`/`namespaces[ns].hub` so the namespace routes to that hub. To **list** available kegs, query a hub: `tap keg list` / the `keg_list` MCP -tool (backed by `GET /api/v1/kegs`). There is no local keg-alias listing. +tool (backed by `GET /api/v1/kegs`). (`tap hub list` lists configured *hub connections*, not kegs.) **`tap keg create`** is namespace-centric too: a bare `tap keg create ` -resolves the default namespace+hub (typically a remote create via -`POST /api/v1/@/kegs`, failing on 409); `tap keg create @local/` pins -the local filesystem hub. When nothing is configured (no user config), the full +resolves the default namespace and Hub, then creates via +`POST /api/v1/@/kegs` (failing on 409). When nothing is configured, the full `tap` surface refuses with a "run `tap bootstrap`" error (`ErrNotBootstrapped`) -rather than silently creating a hidden local keg; `tap keg create ---project`/`--path` (explicit local destinations) still work without setup. -(`tap init` remains as a hidden back-compat alias.) +rather than silently creating local state. `tap init` and the local creation +flags are removed. **Command groups.** `tap keg` administers kegs on a hub (`list`, `create`, `grants`/`grant`/`revoke` for ACLs, `visibility`, `rename`, and `settings` for @@ -222,19 +213,15 @@ the keg's own config — formerly `tap settings`). `tap namespace` administers n and membership roles (`list`, `members`, `add-member`, `set-role`, `remove-member`, `create`). `tap hub` manages hub *connections* (`list`, `status`, `add`/`remove` writing user config, `set-default` writing project -config by default, `--user` for user). These are full-`tap` commands (not in -the pruned `keg` binary). `tap config edit` now defaults to the **project** +config by default, `--user` for user). `tap config edit` defaults to the **project** config; `--user` targets the user config. **Keg selection is flag-driven, not positional.** The keg an admin command operates on comes from the global resolution flags — `--keg` (a bare name or -`@namespace/keg`; a path also works), with `--namespace`/`--hub` as component +`@namespace/keg`), with `--namespace`/`--hub` as component overrides — not a positional. A bare invocation (no `--keg`) targets the -resolved keg. The on-disk discovery selectors `--project`/`--cwd` are gone from -the tap surface (a local keg resolves through the namespace chain like a remote -one); `--path` now means "use this config file, bypassing the cascade" (an alias -of `--config`, for testing). The pruned `keg` binary still resolves the -project-local keg automatically. +resolved KEG. The on-disk discovery selectors `--project`/`--cwd` are gone; +`--path` is not a KEG target selector. **`tap use`** records resolution in config: `tap use @ns/keg` sets the project's `defaultKeg` (in `.tapper/config.yaml`); `tap use @ns/keg --user` sets the @@ -245,15 +232,16 @@ that set each. A persisted `flight` auto-applies when `--flight` is omitted. Supported env vars: `TAP_DEFAULT_KEG`, `TAP_FALLBACK_KEG`, `TAP_FLIGHT`, `TAP_AGENT`, `TAP_LOG_FILE`, `TAP_LOG_LEVEL`, `TAP_DEFAULT_HUB`, `TAP_FALLBACK_HUB`, `TAP_DEFAULT_NAMESPACE`, `TAP_FALLBACK_NAMESPACE`, `TAP_DISABLE_ATLAS_HUB`, -`TAP_DISABLE_LOCAL_HUB`, `TAP_DISABLE_TELEMETRY` (`1`/`true`/`yes`/`on` for +`TAP_DISABLE_TELEMETRY` (`1`/`true`/`yes`/`on` for the disable flags). Use `tap config --explain FIELD` to see which source set a value, or `tap config --show-sources` for all fields. The `--strict` flag makes config load warnings (corrupt YAML) into hard errors. -Keg config (`/keg`) is separate from tapper config — different -schema, different purpose (keg metadata vs tapper resolution). +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. ### Dependency: cli-toolkit @@ -281,10 +269,8 @@ environment and break test isolation. Specifically: The `cli-toolkit` `clock.Clock` interface only exposes `Now()`; it does not provide `After`, `NewTicker`, `AfterFunc`, or similar scheduling primitives. -For in-memory timing that must remain deterministic under a frozen test clock, -prefer channel/condition-variable signalling (see `MemoryRepo.LockNode` for the -pattern) rather than polling. The following call sites use the standard `time` -package directly because each one is either (a) coalescing real filesystem or +The following call sites use the standard `time` package directly because each +one is either (a) coalescing real filesystem or network events whose timing is wall-clock by definition, or (b) a non-time use of `time.Now()` that cannot be driven by a fake clock: @@ -304,12 +290,6 @@ use of `time.Now()` that cannot be driven by a fake clock: - `pkg/keg/keg_remote_events.go` (websocket reconnect backoff timer): the live watch retries real network dials against the hub, so the backoff must measure wall-clock time regardless of the local test clock. -- `pkg/keg/repo_filesystem.go` (100ms retry delay in `FsRepo.WithNodeLock`): - waits on a cross-process `mkdir` lock directory. The retry interval is a - real-time yield between filesystem attempts; there is no in-process signal - the sibling process could deliver. -- `pkg/keg/repo_filesystem_lock.go` (100ms retry delay in `FsRepo.AcquireLock`): - same rationale as `repo_filesystem.go` — cross-process filesystem lock retry. - `pkg/keg/node_id.go` (crypto/rand fallback uses `time.Now().UnixNano()`): used as an entropy source for a short random code when `crypto/rand` fails, not as a time measurement. @@ -320,25 +300,24 @@ use of `time.Now()` that cannot be driven by a fake clock: ### Concurrency Model - **Per-node locking**: `Repository.WithNodeLock(ctx, id, fn)` serializes - operations on a single node. FsRepo uses atomic `mkdir` of a `.keg-lock` - directory with optional process metadata for stale lock detection. MemoryRepo - uses in-process mutex + map. + operations on a single node. Production `PgRepo` enforces this at the Hub; + the concurrency-safe in-memory implementation exists only in tests. - **Lock context propagation**: `contextWithNodeLock`/`contextHasNodeLock` allow re-entrant locking within the same call chain. - **Dex mutex**: `Dex.mu sync.RWMutex` guards index data; `LocalKeg.dexMu` guards lazy initialization. -- **FsRepo.Next()**: Uses atomic mkdir loop to prevent duplicate ID allocation - across concurrent callers. +- **Node allocation**: Production allocation is a Hub operation backed by + PostgreSQL; repository-independent orchestration tests use the internal + in-memory repository. - **KegService cache**: `cacheMu sync.Mutex` guards the shared keg resolution cache. - **Remote operations are single-request**: each `RemoteKeg` method is one HTTP round trip, and the hub serializes per-node writes server-side (`pg_advisory_xact_lock`). There is no client-side lock lease or dex write over HTTP. -- **Cross-process locks are session primitives**: `Keg.Lock`/`Unlock`/ +- **Advisory locks are session primitives**: `Keg.Lock`/`Unlock`/ `LockStatus`/`ForceUnlock` (used by `tap lock` / `tap edit`) are opt-in - advisory locks — backed by `RepositoryLock` locally and the hub's - `/nodes/{id}/lock` endpoints remotely. Leases carry a TTL + advisory locks backed by the Hub's `/nodes/{id}/lock` endpoints. Leases carry a TTL (`DefaultLockTTL`, 5 minutes) with **no renewal**: a session that outlives the TTL loses the lock. @@ -349,8 +328,10 @@ use of `time.Now()` that cannot be driven by a fake clock: hasher, test logger). - **Fixtures**: `pkg/keg/data/` contains `empty`, `example`, `home` fixtures. `pkg/tapper/data/` contains `basic`, `example`, `keep`. -- **MemoryRepo for speed**: Prefer `NewMemoryRepo(rt)` for unit tests; use - FsRepo + sandbox only when testing filesystem behavior. +- **Repository fixtures**: repository-independent behavior tests use + `internal/testkegrepo`, which is imported only by `_test.go` files. SQL, + transaction, restart, and namespace-isolation behavior stays in Tapper Hub's + PostgreSQL integration suite. - **Testify**: Uses `github.com/stretchr/testify/require` for assertions. - **Race detection**: Run `go test -race ./pkg/keg/...` and `go test -race ./pkg/tapper/...` to verify concurrent safety. @@ -421,7 +402,7 @@ When adding or modifying a feature, update each of these: update the JSON Schema files under `schemas/`: - `schemas/tap-config.json` — tap user/project config schema -- `schemas/keg-config.json` — keg config schema +- `schemas/keg-settings.json` — keg settings schema These schemas are referenced by editors for validation and completion hints. A config field added without a schema update will lack editor support and @@ -437,10 +418,10 @@ validation. separate reads **at the Repository layer**. At the business layer, `Keg.ReadNode` returns the full node state (content, raw meta, stats, asset lists) in one operation — a single round trip on RemoteKeg. -- The keg config file is named `keg` (no extension), though `keg.yaml` and +- The keg settings file is named `keg` (no extension), though `keg.yaml` and `keg.yml` are also accepted. -- `FsRepo.Next()` creates the node directory as a reservation — `WriteContent` - must handle pre-existing directories. +- Node IDs are allocated by the Hub. `GET /nodes/next` is only a read-only + probe; creation uses `POST /nodes` with complete content. - **Cobra skips PersistentPostRunE when RunE returns an error.** Any cleanup or logging that must run on both success and failure paths cannot rely on PersistentPostRunE. In tapper, invocation logging and log file cleanup are diff --git a/README.md b/README.md index 4d894a1d..924b9afd 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,12 @@ connects the memory around them. brew install jlrickert/formulae/tapper ``` -Optional project-local profile: - -```bash -brew install jlrickert/formulae/keg -``` - ### From Source Prerequisite: Go `1.26.0` or newer. ```bash go install github.com/jlrickert/tapper/cmd/tap@latest -go install github.com/jlrickert/tapper/cmd/keg@latest ``` If needed, add your Go bin directory to `PATH`: @@ -77,15 +70,16 @@ Verify installation: tap --help ``` -## Start On One Machine +## Get Started -This path creates a local knowledge base and makes plain `tap` commands resolve -to it. +Tapper stores KEGs in a Tapper Hub. Bootstrap the hosted service or provide an +enterprise Hub endpoint, then authenticate. ```bash -tap bootstrap --kind local --default-keg @local/personal -tap keg create @local/personal -tap use @local/personal --user +tap bootstrap --kind cloud +tap auth login +tap keg create personal +tap use personal --user ``` Create your first memory: @@ -250,8 +244,7 @@ Tapper resolves a keg reference through this chain: 4. built-in defaults, unless disabled A keg reference is usually a bare name or `@namespace/name`. The namespace -selects the hub, and the hub selects the backend. Local kegs live at -`/@/`. +selects the remote Hub that owns the KEG. Only user config may define hubs and credentials. Project config can select defaults for a repository, but it cannot introduce a new hub target or token. @@ -262,10 +255,11 @@ Direct CLI commands still use normal keg authorization and do not have their access reduced by flight cover caps. Persist a project flight with either `tap use --flight @namespace/+slug` or the -default-namespace shorthand `tap use +slug`. A config-driven MCP connection -adopts that selection only after an explicit `orient` call. A launcher-bound -`tap mcp --flight REF` connection keeps its flight identity for the process -lifetime while each orientation refreshes that flight's current details. +default-namespace shorthand `tap use +slug`. The next MCP connection pins that +selection as its root. Within a connection, every +authority-bearing MCP call reloads the live bounded graph, defaults to the +root, and may select an accessible transitive descendant; configuration changes +cannot replace the root. ## Release And Contribution Notes @@ -280,8 +274,7 @@ commit and tag, then runs GoReleaser. ## Repository Layout - `cmd/tap` - full CLI entrypoint -- `cmd/keg` - project-local CLI profile - `pkg/tapper` - config, resolution, hub, and service layer -- `pkg/keg` - KEG primitives and repository implementation +- `pkg/keg` - KEG primitives, remote client, and Hub-side orchestration - `pkg/mcp` - MCP server and tool surface - `docs/` - user and contributor documentation diff --git a/docs/README.md b/docs/README.md index bc2acf3f..89d2504f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ structure KEGs, connect agents, and operate the system safely over time. | You need to... | Read | | --- | --- | -| Set up local or hosted Tapper defaults | [Configuration Overview](configuration/README.md) | +| Set up hosted Tapper defaults | [Configuration Overview](configuration/README.md) | | Make a repo resolve to the right team keg | [Project Config](configuration/project-config.md) | | Understand `@namespace/keg` resolution | [Resolution Order](configuration/resolution-order.md) | | Connect AI agents to shared memory | [AI Coding Agents](ai-coding-agents/README.md) | @@ -32,13 +32,14 @@ structure KEGs, connect agents, and operate the system safely over time. ### Bootstrap A Machine ```bash -tap bootstrap --kind local --default-keg @local/personal -tap keg create @local/personal -tap use @local/personal --user +tap bootstrap --kind cloud +tap auth login +tap keg create personal +tap use personal --user ``` -For hosted or enterprise deployments, use `tap bootstrap --kind cloud` or -`tap bootstrap --kind enterprise --endpoint `, then `tap auth login`. +For an enterprise deployment, use +`tap bootstrap --kind enterprise --endpoint ` instead. ### Work In A Team Keg diff --git a/docs/ai-coding-agents/README.md b/docs/ai-coding-agents/README.md index 041d9f28..28eeb5a2 100644 --- a/docs/ai-coding-agents/README.md +++ b/docs/ai-coding-agents/README.md @@ -23,7 +23,7 @@ configuration only for generic hosts without a native Tapper plugin. - [MCP Server Setup](mcp-setup.md) — manual setup for hosts that do not use a bundled integration: `claude mcp add`, JSON config for arbitrary MCP clients, and the tool categories exposed over MCP. -- [Provider-neutral launcher composition](launchers.md) — future immutable +- [Provider-neutral launcher composition](launchers.md) — future pinned-root `--flight` process binding across agent hosts and runtimes. - [Agent Conventions](agent-conventions.md) — tapper invariants every agent should follow: MCP-first, never edit node files directly, never mix CLI diff --git a/docs/ai-coding-agents/agent-conventions.md b/docs/ai-coding-agents/agent-conventions.md index 103c0c8c..35af432b 100644 --- a/docs/ai-coding-agents/agent-conventions.md +++ b/docs/ai-coding-agents/agent-conventions.md @@ -47,19 +47,12 @@ Always route through tapper's interfaces: automatically. - `mcp__tapper__meta` to update metadata without touching content. -## Never mix CLI writes with a live MCP session +## CLI and MCP share Hub concurrency rules -Running `tap create` or `tap edit` while an MCP server is serving the -same keg can: - -- Cause lock contention: index rebuilds (triggered by filesystem - events) hold the index mutex, which can block MCP list operations. -- Produce stale index reads: the MCP server's in-memory index does not - observe CLI-originated writes until the next filesystem event is - processed, which may be delayed by the watcher's debounce window. - -If you must drop to CLI, finish the MCP session first (`claude /mcp` -disable, or end the agent session). Resume the MCP session afterward. +CLI and MCP operations both go through Hub-compatible HTTP APIs. Durable +mutations serialize on the Hub and protected writes require current hashes. +Do not bypass MCP during an agent session: use the native tools so authority, +snapshots, and structured conflict recovery remain visible to the agent. ## Snapshot before large in-place edits; preserve content before remove @@ -123,25 +116,19 @@ is in the same keg — they resolve in any markdown renderer. - Per-node writes are serialized. Two tools editing the same node will queue, not race. -- Cross-node operations run concurrently. If you edit several nodes in - parallel, each edit is independently consistent, but there is no - cross-node transaction. +- Batch operations use the Hub's aggregate transaction boundary. Independent + single-node operations may run concurrently. - The index is rebuilt incrementally on write. Searches issued immediately after a write see the new state. -- A live watcher (the MCP server, or `tap watch`) rebuilds the index - on filesystem events; if it runs against the same keg as concurrent - writes, expect additional latency during burst writes. +- `tap watch` consumes Hub events; it does not discover local files. ## Do not bypass completeness checks -- `mcp__tapper__create` allocates a new numbered directory atomically. - Do not pre-create directories with `mkdir` and hope tapper will use - them. -- `mcp__tapper__remove` runs integrity checks and updates the index. - Do not `rm -rf` a node directory. +- `mcp__tapper__create` asks the Hub to allocate and create a node atomically. +- `mcp__tapper__remove` runs integrity checks and updates the index. Filesystem + deletion is not a Tapper operation. - `mcp__tapper__move` handles ID reassignment and updates backlinks. - Do not rename node directories by hand. -If a tool refuses an operation, the refusal is load-bearing — there -is a consistency reason. Do not work around it by dropping to the -filesystem. +If a tool refuses an operation, the refusal is load-bearing — there is a +consistency or authority reason. Do not work around it through direct HTTP or +database access. diff --git a/docs/ai-coding-agents/claude-code-plugin.md b/docs/ai-coding-agents/claude-code-plugin.md index 7dc982e9..7976e342 100644 --- a/docs/ai-coding-agents/claude-code-plugin.md +++ b/docs/ai-coding-agents/claude-code-plugin.md @@ -48,9 +48,10 @@ Claude session for the refreshed plugin and MCP connection to take effect. A conflict. The baseline plugin distributes only the `tapper` skill; `tapper-dev` remains a -separately installed optional plugin. Flight changes use normal Tapper -configuration followed by an explicit `orient` call on the existing MCP -connection. The plugin ships no separate management skills, hidden switch +separately installed optional plugin. Root changes use normal Tapper +configuration followed by a new MCP connection. On an existing connection, +governed calls default to that root or select an accessible transitive +descendant with `flight`. The plugin ships no separate management skills, hidden switch command, or prompt-expansion hook. If the MCP tools are unavailable, report the unavailable connection, ask the user to reconnect or restart the host session, and never kill or signal host-owned processes. diff --git a/docs/ai-coding-agents/codex.md b/docs/ai-coding-agents/codex.md index 5c45622e..64780ced 100644 --- a/docs/ai-coding-agents/codex.md +++ b/docs/ai-coding-agents/codex.md @@ -54,13 +54,18 @@ until Codex exposes native project scopes. The baseline plugin distributes only the `tapper` skill; `tapper-dev` remains a separately installed optional plugin. It ships no separate management skills or -hidden controls. To change a config-driven session, run `tap use --flight -@namespace/+slug` (or `tap use +slug`) and call `mcp__tapper__orient` in the -existing thread. +hidden controls. To change roots, run `tap use --flight @namespace/+slug` (or +`tap use +slug`) and start a new thread. Within an existing thread, +authority-bearing calls default to that root and may select an accessible +transitive descendant with `flight`. -If no flight is selected, MCP still connects in recovery-only mode. Codex can -use `orient`, `list_flights`, `flight_show`, and credential-safe `auth_info`, while KEG tools -remain locked. Ask the user to run `tap use --flight @namespace/+slug`, then -call `mcp__tapper__orient` to restore the normal tool surface. If the MCP tools -are unavailable, report the unavailable connection, ask the user to reconnect -or restart the host session, and never kill or signal host-owned processes. +If no flight is selected, MCP connects with the complete tool surface and +normal identity-authorized full access. Bare calls can use every accessible KEG +at Codex's real role; explicit `flight` selects one listed real flight without +inheriting no-flight authority. Ask the user to create or choose a +least-privilege flight, run `tap use --flight @namespace/+slug`, and start a +new thread. `session_refresh` cannot narrow the current connection. If only +the recovery tools appear, an explicitly configured root failed to initialize. +If MCP tools are unavailable, report the unavailable connection, ask the user +to reconnect or restart the host session, and never kill or signal host-owned +processes. diff --git a/docs/ai-coding-agents/launchers.md b/docs/ai-coding-agents/launchers.md index 1ca5cd9e..5daec743 100644 --- a/docs/ai-coding-agents/launchers.md +++ b/docs/ai-coding-agents/launchers.md @@ -1,22 +1,33 @@ # Provider-neutral launcher composition -A future launcher can bind an agent process to one immutable flight identity by -starting Tapper as `tap mcp --flight @namespace/+slug`. Orientation still -refreshes that flight's latest manifest and instructions, but shared -configuration cannot redirect the process to another flight. +`tap launch` binds an agent process to one connection-pinned Hub-backed flight root. +Set `flight: @namespace/+slug` in Tapper configuration or export +`TAP_FLIGHT=@namespace/+slug`; the launcher validates that the namespace routes +to a remote Hub before starting the harness. Flights are always Hub-backed. +For a one-shot root that does not rewrite shared configuration, pass the global +flag directly: + +```sh +tap launch claude --flight @namespace/+slug +``` + +Every authority-bearing MCP call reloads the root's live graph without allowing +shared configuration to redirect the running process to another root. The +controller may select any identity-accessible flattened descendant explicitly; +that flight contributes independent instructions and authority for that call. The launcher specification is deliberately provider-neutral: 1. Choose an agent host and model command. -2. Start a dedicated Tapper MCP process with a static `--flight` argument. +2. Resolve and validate the configured canonical Hub-backed launch root. 3. Connect the host to that process over its supported MCP transport. -4. Require initialization/orientation before KEG work. +4. Require initialization followed by `orient` before KEG work. 5. Keep durable task and plan state in ordinary runtime-interpreted KEG notes; do not create a local agent-session registry. 6. On restart, initialize again and recover durable work from those notes. Multiple launcher-bound processes may use different flights in the same -project without rewriting shared configuration. This stronger immutable mode is +project without rewriting shared configuration. This stronger pinned-root mode is recommended when preventing accidental self-expansion matters. Config-driven mode remains convenient for deliberate temporary switching. Neither mode replaces operating-system sandboxing or separate credentials. @@ -27,5 +38,4 @@ Host composition should follow each provider's native configuration surface: - [Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) - [Ollama launch composition](https://ollama.com/blog/launch) -This document specifies composition only; Tapper does not implement the -launcher in this change. +The launcher is experimental. diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index 8e5eedd4..943d993d 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -4,9 +4,10 @@ The `tap mcp` command starts a Model Context Protocol server on stdio, exposing the same agent-safe tools and resources as Tapper Hub's authenticated `/mcp` endpoint — the one difference being attachment transfers, where `tap mcp` can also read and write local paths because it runs on your machine. Both publish -immutable flight authority at initialization and on explicit orientation. -Without one, the server starts in a recovery-only state so the host can inspect -flights safely. This page is the advanced manual path for MCP hosts that are not +connection-pinned authority at initialization. Orientation is a read-only view; +`session_refresh` retries only a broken explicit selection. Without a flight, +the server uses normal identity-authorized full access for the connection +lifetime. This page is the advanced manual path for MCP hosts that are not using the bundled Claude Code or Codex integrations. Most users should use the official one-command installs in the project README's @@ -78,11 +79,14 @@ The MCP server exposes a shared agent surface rather than every machine-local CLI capability. Exact tool availability follows the installed Tapper version and the active flight; inspect your MCP host's tool list for the live surface. -When no flight is selected, the visible list is intentionally restricted to -`orient`, `list_flights`, `flight_show`, and `auth_info`. `orient` -and any guessed KEG-tool call explain that KEG tools are locked and direct the -agent to inspect flights through MCP, ask the user to run `tap use --flight -@namespace/+slug`, and call `orient` again on the same connection. +When no flight is selected, the complete tool inventory is visible. Bare calls +use every identity-accessible KEG at the caller's real role, and explicit +`flight` selects any listed real flight for that call. No-flight authority +never raises Hub ACLs or namespace membership. Create or choose a +least-privilege flight, pin it with `tap use --flight @namespace/+slug` (or +`tap mcp --flight`), and start a new connection. `session_refresh` cannot +narrow the existing connection. Seeing only the six recovery tools means an +explicitly configured root failed to initialize. ### Read @@ -103,11 +107,11 @@ agent to inspect flights through MCP, ask the user to run `tap use --flight | Tool | Description | | --- | --- | | `create` | Atomically create 1–100 nodes from `nodes[]`; unique keys support forward/backward `{{node:key}}` body references | -| `edit` | Atomically replace 1–100 nodes from `edits[]`, with optional hash checks and pre-edit snapshots | -| `meta` | Read `node_ids[]` or atomically replace metadata through `updates[]` | -| `remove` | Delete a node | -| `move` | Move a node to a different ID | -| `keg_settings_edit` | Replace the complete validated KEG YAML document; requires admin flight authority and editor/admin KEG access | +| `edit` | Read with `cat`, then atomically replace 1–100 nodes from `edits[]`; every item requires that node's `expected_hash` | +| `meta` | Read `node_ids[]` without tokens, or read with `cat` and atomically replace metadata through `updates[]`; every update requires that node's `expected_hash` | +| `remove` | Read with `cat`, then atomically delete 1–100 `nodes[]`; every item requires its own `expected_hash` | +| `move` | Read with `cat`, then move a node using its required `expected_hash` | +| `keg_settings_edit` | Read the full document with `keg_settings`, then replace it using its required `expected_hash`; requires admin flight authority and editor/admin KEG access | Mutation inputs are array-only and each array contains 1–100 items: @@ -116,14 +120,23 @@ Mutation inputs are array-only and each array contains 1–100 items: {"edits":[{"node_id":"12","content":"# Revised","expected_hash":"...","snapshot_before":true}]} {"node_ids":["12","13"]} {"updates":[{"node_id":"12","content":"type: plan\n","expected_hash":"...","snapshot_before":true}]} +{"nodes":[{"node_id":"12","expected_hash":"..."},{"node_id":"13","expected_hash":"..."}]} {"nodes":[{"node_id":"12","message":"reviewed"}]} ``` The first and last shapes belong to `create` and `node_snapshot`; the middle -three belong to `edit` and the two mutually exclusive `meta` modes. Mutation -results preserve request order and report `node_id`, the resulting hash or -snapshot revision, and advisory schema validation details when applicable. A -failed batch returns no partial results and commits none of its changes. +shapes cover `edit`, the two mutually exclusive `meta` modes, and `remove`. +Mutation results preserve request order and report `node_id`, the resulting +hash or snapshot revision, and advisory schema validation details when +applicable. A failed batch returns no partial results and commits none of its +changes. + +Every protected mutation names the read that supplies its token: `cat` for +node edits, metadata updates, moves, and removals; `keg_settings` for settings; +`schema_read` for schema edits/deletes; and `flight_show` for flight +edits/deletes. A conflict performs no operation and returns the current hash +and, when practical, current content. Merge or refetch, then retry with that +current hash. ### Index, Diagnostics, And Safety @@ -161,8 +174,9 @@ vocabulary to ask. | Tool | Description | | --- | --- | -| `keg_list` | List identity-authorized kegs filtered through the active flight | -| `auth_info` | Return structured credential-safe `identities[]` and flight-filtered `kegs[]` | +| `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 | +| `auth_info` | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | Each identity includes only its hub locator, user ID, username, display name, default namespace, and namespace names. Tokens, email, scopes, cookies, expiry, @@ -174,21 +188,22 @@ authenticated Hub identity; hosted MCP reports its single authenticated user. | Tool | Description | | --- | --- | | `import_from_keg` | Import nodes from another keg | -| `orient` | Return the shared KEG system orientation payload | +| `orient` | Return a read-only view of current instructions, selectable flights, and KEGs | +| `session_refresh` | Retry activation after a broken explicit selection is repaired; zero arguments and no authority replacement once active | | `list_flights`, `flight_show` | Discover and inspect visible flights | -| `flight_create`, `flight_edit`, `flight_delete` | Manage Hub-backed flights when the active flight grants `manage_flights` and the identity owns/administers the target namespace | +| `flight_create`, `flight_edit`, `flight_delete` | Manage Hub-backed flights when the active flight grants `manage_flights` and the identity owns/administers the target namespace; edits/deletes require the hash from `flight_show` | MCP does not expose Tapper configuration, config templates, repository setup, archive import/export, raw auth status, license text, keg visibility, or namespace administration. Those remain external CLI, configuration, or Hub UI operations. -The four mutation tools above intentionally use array-only inputs. Empty +The five batch mutation modes above intentionally use array-only inputs. Empty batches, batches over 100 items, duplicate keys/IDs, unknown create -placeholders, stale hashes, invalid schemas, or any persistence failure reject -the entire call. Structured results preserve request order and include node -IDs plus resulting hashes or snapshot revisions. The removed single-item -fields are not accepted by the published MCP schemas. +placeholders, missing or stale hashes, invalid schemas, or any persistence +failure reject the entire call. Structured results preserve request order and +include node IDs plus resulting hashes or snapshot revisions. The removed +single-item fields are not accepted by the published MCP schemas. `import_from_keg` requires editor identity and flight authority on the source when `leave_stubs` is requested, because that option rewrites source nodes. @@ -208,15 +223,18 @@ server default. This enables multi-keg workflows without restarting the server: Use the per-tool `keg` parameter for cross-keg work. Do not restart the MCP server just to switch between organization kegs. -There is no model-visible per-call `flight` parameter. The active flight is -server-owned session state. Config-driven servers adopt configuration changes -only through explicit orientation; `tap mcp --flight` stays bound to that -identity for its process lifetime. +Every authority-bearing MCP tool accepts an optional per-call `flight`. +Omission uses pinned-root authority for operations, while default `orient` and +`keg_list` discovery aggregate the root and accessible descendants. Supplying +the pinned root or a listed descendant selects exactly that flight. The root +reference stays pinned to the connection, and every call reloads its live +graph and authority. -Hosted `/mcp` instead selects the authenticated account's global MCP flight -preference. A successful self-edit adopts the exact returned flight immediately; -a self-delete enters recovery immediately. Mutation tools disappear as soon as -the adopted flight no longer grants `manage_flights`. +Hosted `/mcp` pins the authenticated account's MCP flight preference when the +connection initializes. Later manifest, relation, cover, identity-role, and ACL +changes are adopted on the next call. Deleting or losing the connection-pinned +root makes that connection permanently unavailable, so selecting a different +root requires a new launch. ## Troubleshooting @@ -231,19 +249,23 @@ tap mcp --help ### No Flight Configured -The server connects in recovery-only mode rather than failing startup. Inspect -the available flights through `list_flights` and `flight_show`, then configure -a project flight or start the MCP server with an explicit flight: +The server connects with identity-authorized full access. Inspect the available +flights through `list_flights` and `flight_show`, then configure a +least-privilege project flight or start the MCP server with an explicit flight: ```bash tap use --flight @acme/+release-42 tap mcp --flight @acme/+release-42 ``` -After `tap use`, call `orient` on the existing session. A failed ordinary -refresh keeps the last valid authority; a blank selection intentionally enters -recovery mode. If local configuration still names a flight deleted through MCP, -`orient` reports the stale external reference until configuration is changed. +After `tap use`, disconnect and start a new MCP connection. The existing +connection remains on no-flight full access, and `session_refresh` reports +`already_active` with `nextAction:"new_session"`. + +If a non-empty configured flight is missing, inaccessible, or unavailable, the +server fails closed into recovery mode. Repair that exact selection outside +MCP, then call `session_refresh` and `orient`; it never falls back to +no-flight full access. ### Logs diff --git a/docs/ai-coding-agents/orient.md b/docs/ai-coding-agents/orient.md index 66f70480..d8b7fc93 100644 --- a/docs/ai-coding-agents/orient.md +++ b/docs/ai-coding-agents/orient.md @@ -1,9 +1,10 @@ # Orientation Surface The `orient` surface gives an agent one deterministic bootstrap payload for -operating against tapper KEGs. The same bytes are reachable three ways: the -`mcp__tapper__orient` tool, the `tapper://orient` MCP resource, and the -`tap orient` CLI. All three delegate to `Tap.Orient`. +operating against tapper KEGs. The `mcp__tapper__orient` tool and +`tapper://orient` MCP resource return identical session-aware bytes. The +`tap orient` CLI uses the same canonical renderer for a direct flight preview, +but it has no pinned session graph to aggregate. ## Payload Order @@ -11,8 +12,12 @@ The payload always starts with KEG system context: 1. KEG purpose and core rules. 2. Available KEGs, including each canonical reference, title, concise summary, - role, source, and active flight cap when a flight is selected. -3. Flight title and instructions, when a flight is active. + highest effective role, source, and every canonical flight granting that + winning role. Default MCP orientation aggregates the pinned root and its + accessible transitive descendants; explicit `flight` is exact. +3. Connection-pinned root, selected flight, root-first ordered breadth-first selectable flights, + selected canonical path, revision, and only the selected flight's title and + instructions. 4. A prompt to request targeted KEG settings before operating. 5. Canonical Tapper agent guidance: linking, snapshots, tool inventory, and troubleshooting. @@ -23,20 +28,24 @@ a warning about what was skipped. ## Parameters -The CLI accepts an optional flight preview. The MCP tool accepts `{}` and -refreshes the server-owned session orientation. +The CLI accepts an optional flight preview. The MCP tool and every other +authority-bearing tool accept an optional top-level `flight`. Omission keeps +pinned-root operational authority; for `orient` and `keg_list` only, omission +renders graph-wide discovery. Supplying `flight` always selects an exact root +or accessible descendant. | Parameter | Values | Effect | | --- | --- | --- | -| `flight` (CLI only) | flight identifier, for example `@acme/+release-42` | Renders flight title/instructions and caps the available KEG list to the flight cover. | +| `flight` (CLI) | flight identifier, for example `@acme/+release-42` | Previews that flight's title, instructions, and KEG cover. | +| `flight` (MCP) | pinned root, canonical descendant, or root-namespace `+slug` | Selects the root or an identity-accessible transitive descendant for this call. Ancestor instructions and authority are not inherited. | `tap orient` rejects KEG, namespace, and hub targeting flags. Direct CLI KEG -commands ignore flight cover caps and use normal authorization; MCP tools -enforce the most recently published orientation. +commands ignore flight cover caps and use normal authorization; MCP tools load +and enforce a fresh call-local orientation independently for every call. ## Progressive KEG Guidance -`summary` and `instructions` have distinct roles in a KEG config: +`summary` and `instructions` have distinct roles in a KEG settings: ```yaml kegv: 2025-07 @@ -47,7 +56,8 @@ instructions: | Snapshot any node before changing public API guidance. ``` -`summary` is the concise discovery description shown by aggregate orientation. +`summary` is the concise discovery description shown by graph orientation and +identity `keg_search`. 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, even under `full_access`. @@ -66,7 +76,9 @@ together: ## MCP Tool -The MCP server registers a single `orient` tool: +The MCP server registers read-only `orient` plus zero-argument +`session_refresh`. Initialization pins authority internally but returns only a +minimal directive to call `orient`. `orient` never changes session state: ```json { @@ -80,7 +92,29 @@ The MCP server registers a single `orient` tool: } ``` -The response body is one markdown text block containing the payload. +The response body is one markdown text block containing the payload. If live +flight, identity, grant, visibility, KEG, or relation authority changes, the +next call adopts them automatically. A race between Tapper's resolution and +Hub validation returns `ORIENTATION_STALE` without performing the operation. +Fresh permission or selection failures return `ORIENTATION_DENIED`; transient +Hub failures return `ORIENTATION_UNAVAILABLE`; permanent root loss returns +`ORIENTATION_ROOT_UNAVAILABLE`. Mutations are never replayed automatically. + +When initialization could not activate an explicitly configured real flight, +repairing that same selection is adopted only by `session_refresh`. It returns +`activated`, `already_active`, or `selection_required` as structured +status. Activation directs the caller to `orient`. An already-active refresh +makes no provider call and cannot replace connection-pinned authority. For a +no-flight connection it reports `nextAction:"new_session"`, because narrowing +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. ## MCP Resource @@ -92,7 +126,10 @@ tapper://orient ``` `resources/read` on that URI returns bytes byte-identical to a bare -`mcp__tapper__orient` call. +`mcp__tapper__orient` call. It is read-only in every mode. Resources have no +flight parameter, so no-flight sessions use identity authority, real-root +sessions use root authority and graph-wide discovery, and failed explicit +selections return their published recovery state. ## CLI @@ -105,15 +142,17 @@ tap orient --flight @acme/+release-42 ``` `--flight` is a root persistent CLI flag available on commands that accept -`--keg`. It is free-form and suppresses filesystem completion; it is not part -of any ordinary MCP tool schema. MCP flight selection is fixed by the human -session boundary and cannot be overridden by `orient` or `keg_settings`. +`--keg`. It is free-form and suppresses filesystem completion. MCP root +selection is fixed by the human session boundary; the optional MCP `flight` +field selects only that root or a flattened descendant for one call and cannot +replace the root. -## Byte-Equivalence Guarantee +## MCP Byte-Equivalence Guarantee -The tool, resource, and CLI all delegate to `Tap.Orient`. Given matching -inputs, every surface returns the same bytes. Tests in `pkg/parity/` enforce -this at CI time. +The MCP tool and resource capture the same live session candidate and return +the same bytes. The CLI shares payload structure and exact-flight rendering, +while MCP adds pinned-root graph discovery. Tests in `pkg/parity/` and +`pkg/mcp/` enforce these contracts at CI time. ## See Also diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3c8cd2eb..c32476eb 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,18 +9,18 @@ Use these docs when you are: - adding or changing CLI commands - changing keg/config resolution behavior -- debugging selection logic between config and project-local kegs +- debugging remote Hub and namespace selection - extending low-level repository behavior - writing integration-style CLI tests ## Layered Model -1. CLI entrypoints (`cmd/tap`, plus optional profile entrypoints such as `cmd/keg`) +1. CLI entrypoint (`cmd/tap`) 2. Cobra command tree and shared dependencies (`pkg/cli`) 3. Tap client and service layer (`pkg/tapper`) 4. KEG domain and repository abstraction (`pkg/keg`) -5. Filesystem or memory-backed storage implementations (`pkg/keg`) -6. Sandbox-backed integration tests (`pkg/cli/*_test.go`, `pkg/tapper/*_test.go`) +5. Hub-side repository implementations (PostgreSQL in Tapper Hub) +6. Remote test servers, repository-independent memory tests, and PostgreSQL integration tests ## Read Next diff --git a/docs/architecture/cli-and-command-flow.md b/docs/architecture/cli-and-command-flow.md index 70895c9f..e38c7ca3 100644 --- a/docs/architecture/cli-and-command-flow.md +++ b/docs/architecture/cli-and-command-flow.md @@ -1,16 +1,11 @@ # CLI And Command Flow This page describes how `tap` executes a command from process start to service -call, and how optional secondary binaries such as `keg` reuse the same -machinery with a different profile. +call. ## Entrypoints - `cmd/tap/tap.go` calls `cli.Run(ctx, rt, os.Args[1:])` -- `cmd/keg/keg.go` calls `cli.RunWithProfile(..., cli.KegProfile())` - -`tap` is the primary binary. `keg` is a secondary binary that demonstrates -how the same command framework can be pruned through profile-based behavior. ## Run Wrapper @@ -41,9 +36,7 @@ reconstruct core services. Most commands follow this shape: 1. Bind command-specific flags into a typed options struct. -2. Merge root KEG target defaults and apply profile-specific behavior. `tap` - uses the full namespace/hub-aware profile. `keg` uses a pruned project-local - profile. +2. Merge root remote KEG target defaults. 3. Call a single method on `deps.Tap`. 4. Write returned output to stdout. @@ -53,30 +46,5 @@ Example command files: - `pkg/cli/cmd_info.go` - `pkg/cli/cmd_repo_config.go` -## Profile Differences - -Profiles are defined in `pkg/cli/profile.go`. - -- `TapProfile` enables the full command surface and namespace/hub targeting. -- `KegProfile` forces project-style resolution and disables configuration - command surfaces that do not fit the narrower workflow. -- Native plugin integration is explicitly profile-gated: only `tap` registers - the public `integrate` command and hidden host-facing `hook` protocol. -- Snapshot/archive commands (`snapshot`, `archive import`, `archive export`) - are shared by both profiles. The main difference is target resolution: - `keg` resolves against the active project by default, while `tap` resolves - through `@namespace/keg` references, config defaults, and hub routing. - -## Why The Profile Technique Matters - -The command tree is defined once in `pkg/cli/cmd_root.go` and then filtered by -the selected `Profile`. - -That gives you: - -- one implementation path for shared commands -- one service graph (`deps.Tap`) regardless of binary name -- the ability to publish a narrower binary without forking command logic - -In practice, `tap` stays the canonical interface and smaller binaries can be -added later when a focused workflow benefits from a reduced surface area. +`TapProfile` in `pkg/cli/profile.go` controls host-integration registration, +while KEG operations always resolve through remote Hub targets. diff --git a/docs/architecture/repository-layer.md b/docs/architecture/repository-layer.md index d9e31f60..cf73261b 100644 --- a/docs/architecture/repository-layer.md +++ b/docs/architecture/repository-layer.md @@ -1,60 +1,23 @@ # Repository Layer -The repository layer is the low-level data exchange boundary for KEG data. +`pkg/keg/repository.go` defines the storage contract used by `LocalKeg` for +node data, indexes, settings, attachments, snapshots, locks, and archives. -## Repository Contract +Tapper clients do not construct a repository. `NewKegFromTarget` accepts only +resolved Hub references and HTTP(S) endpoints and returns a `RemoteKeg`. Each +`RemoteKeg` operation maps to one Hub request. -`pkg/keg/repository.go` defines `Repository`, the core interface used by -high-level keg operations. +Tapper Hub constructs `LocalKeg` with its PostgreSQL repository. That is the +only production persistence path: orchestration, indexing, validation, and +locking remain in `LocalKeg`, while PostgreSQL supplies durable storage. -It covers: +The Tapper test suite has a concurrency-safe in-memory implementation in a +`_test.go` file. It exists solely for repository-independent `LocalKeg` tests +and is neither available nor linked in production builds. Repository behavior +itself is verified by Tapper Hub's PostgreSQL integration suite. -- node lifecycle (next/list/move/delete) -- node data (content/meta/stats) -- indexes (`dex/*`) -- keg config (`keg` file) -- optional capabilities (files/images/snapshots) +This split keeps three boundaries explicit: -Commands and services rely on this contract instead of directly accessing files. - -## Implementations - -Primary implementations in `pkg/keg`: - -- `repo_memory.go` for in-memory repositories -- `repo_filesystem.go` for filesystem-backed repositories -- `repo_memory_snapshots.go` for in-memory revision history -- `repo_filesystem_snapshots.go` for on-disk snapshot storage - -`NewKegFromTarget` in `pkg/keg/keg.go` selects an implementation from a -`kegurl.Target` scheme (`memory` or `file`). - -## High-Level KEG Service - -`pkg/keg/keg.go` wraps the repository with a stateful API: - -- `Init` for keg bootstrap (config + zero node + indexes) -- `Create`, `Read`, `Move`, `Delete` for node lifecycle -- index and query-oriented operations over dex data - -This separation allows command code to stay simple while storage behavior stays -centralized and testable. - -## Snapshot Support - -`RepositorySnapshots` is implemented for both shipped repositories: - -- `MemoryRepo` stores fully materialized revisions in memory for tests and fast - local flows -- `FsRepo` stores per-node history under `snapshots/` with `index.json`, - revision content blobs, and revision metadata/stats files - -This powers `tap` and `keg` snapshot/history commands plus archive -import/export workflows. Archive import reuses source node IDs and overwrites -matching nodes in the target keg. - -## Why The Boundary Matters - -- storage can change without rewriting command handlers -- tests can run against memory repos for fast behavior checks -- file-backed behavior can be exercised independently in filesystem tests +- `RemoteKeg` verifies the HTTP contract and one-round-trip behavior. +- `LocalKeg` tests verify repository-independent orchestration quickly. +- Tapper Hub PostgreSQL tests verify durable repository semantics. diff --git a/docs/architecture/service-layer.md b/docs/architecture/service-layer.md index 065f4d88..90302b1d 100644 --- a/docs/architecture/service-layer.md +++ b/docs/architecture/service-layer.md @@ -47,10 +47,9 @@ Notable behavior: `token`/`tokenEnv` from any walked project config (user config only). Each strip becomes a `ConfigLoadWarning` surfaced by `Config()`; `--strict` escalates warnings to errors. -4. Reference resolution (`Config.ResolveRef`) parses the keg - selector into a reference (`parseKegRef`) and applies the hub and namespace - default/fallback chains plus the per-hub-kind backend mapping (local → - `/@/`; remote/readonly → `/api/v1/@/kegs/`). +4. Reference resolution (`Config.ResolveRef`) parses the keg selector into a + reference (`parseKegRef`), applies the hub and namespace default/fallback + chains, and produces `/api/v1/@/kegs/`. ## KegService @@ -59,10 +58,7 @@ Notable behavior: Resolution modes on the full `tap` surface: 1. explicit `--keg`, optionally refined by `--namespace` or `--hub` -2. implicit resolution from config and cwd - -Project-local resolution still exists for the pruned `keg` profile and for -explicit local creation destinations. +2. implicit resolution from config and workspace `kegMap` rules Default implicit order: @@ -78,15 +74,27 @@ through the namespace-centric chain. `keg.Keg` is the command-operation boundary, not a repository primitive surface. `LocalKeg` owns same-keg orchestration and `RemoteKeg` maps each aggregate method to exactly one authenticated Hub request. Listing, batch -reads, related links, graph/info/doctor, bulk removal/validation, -editor open/save, redirect creation, schema creation, dex reads, create, and +reads, related links, info/doctor, bulk removal/validation, +editor open/save, schema creation, dex reads, create, and lock acquisition therefore have matching local and hosted semantics without client fan-out. The Tap layer groups mixed-keg read and validation arguments by resolved keg, issues one batch per group, and restores caller order. Interactive editing and watching remain separate phases. Cross-keg import uses one source export, one -target import, and, when requested, one source redirect batch. +target import, and, when requested, one atomic source-stub update batch. + +## Mutation preconditions + +Protected mutations enforce optimistic concurrency at the `keg.Keg` boundary, +so CLI, hosted-MCP, and browser callers share one rule. Node content +and metadata use the node state hash; schemas and keg settings hash their stored +YAML documents; flights hash their stored manifests. MCP makes these hashes +required in every corresponding mutation schema. Batch node edits, metadata +updates, and removals carry one token per item and preflight every token before +performing any mutation. A conflict returns the current hash and recovery +content when practical, with `operationPerformed=false`, so callers can merge +or refetch and retry without guessing whether the write landed. Every `Repository` supplies a reentrant keg operation boundary. A read boundary gives aggregate readers one coherent snapshot; a write boundary @@ -95,15 +103,13 @@ reload/mutate/persist cycle. A write may nest reads or writes, a read may nest reads, and a read-to-write upgrade is rejected. The boundary is acquired before node locks, whose order must remain deterministic. -`MemoryRepo` shares a cancellation-aware read/write boundary across every -`LocalKeg` using that repository. `FsRepo` uses one exclusive root lock for -both reads and writes so separate processes cannot expose a half-written -multi-file view; dead-owner metadata is cleaned on acquisition. Hosted -`PgRepo` uses read-only repeatable-read transactions for aggregate reads and +Production `PgRepo` uses read-only repeatable-read transactions for aggregate reads and write transactions that lock the keg's catalog row before taking node locks. This intentionally serializes writes within one keg for correctness. Different kegs remain independently writable; optimistic dex generations/CAS retries are -a possible future throughput optimization. +a possible future throughput optimization. The only non-PostgreSQL repository +is the concurrency-safe internal test helper used for repository-independent +`LocalKeg` orchestration tests. ## FlightService and flight gating @@ -113,8 +119,11 @@ is resolved, `Tap.enforceFlight` rejects MCP access to a keg that falls outside the flight's cover or tries to write through a `viewer` cap. Direct CLI commands set `KegTargetOptions.BypassFlightRestrictions`, so they keep normal keg authorization while preserving `Flight` for orient/instruction rendering. -An empty cover denies every KEG. MCP sessions publish an immutable orientation -snapshot at initialization and on explicit orientation. Config-driven sessions -may adopt another configured flight; `tap mcp --flight` keeps a static flight -identity while refreshing that flight's current details. KEG selectors remain -independent operation defaults. See [Flights](../configuration/flights.md). +An empty cover denies every KEG. MCP sessions pin either no-flight identity +authority or one real root at initialization. No-flight calls may use any +identity-accessible real flight explicitly; real-root calls may use that root +or one of its currently accessible transitive descendants. `orient` is a +read-only view and `session_refresh` only activates a repaired, explicitly +configured root. Config and preference changes cannot replace active authority +within a connection. KEG selectors remain independent operation defaults. See +[Flights](../configuration/flights.md). diff --git a/docs/architecture/testing-architecture.md b/docs/architecture/testing-architecture.md index 2a495e92..0a8f942b 100644 --- a/docs/architecture/testing-architecture.md +++ b/docs/architecture/testing-architecture.md @@ -1,56 +1,30 @@ # Testing Architecture -tapper uses unit tests and integration-style CLI tests with a sandbox runtime. +Tapper uses three complementary test layers. -## Unit Tests +## Repository-independent orchestration -Unit tests live beside implementation files (for example `pkg/keg/*_test.go`). +`pkg/keg` tests construct `LocalKeg` over a concurrency-safe in-memory +repository defined only in test files. These tests cover orchestration, +validation, indexes, snapshots, locks, attachments, and archives without +making a filesystem repository part of the product. -They focus on: +The memory repository must satisfy `Repository` and every optional capability +that a test exercises. Compile-time interface assertions catch contract drift, +and race/concurrency tests exercise its locking behavior. It is a test double, +not a persistence implementation. -- pure behavior of domain and service methods -- deterministic config and resolution behavior -- repository-specific edge cases +## Remote client and command surfaces -## Sandbox Integration Pattern +`RemoteKeg`, CLI, and MCP tests use Hub-compatible `httptest` servers. They +assert request paths, authentication, conditional hashes, serialization, +errors, and remote-only resolution. Filesystem paths and `file://` targets are +negative cases. -CLI integration tests use `github.com/jlrickert/cli-toolkit/sandbox`. +## PostgreSQL integration -Common setup pattern: - -1. Build a sandbox with fixture data (`NewSandbox(...)` in test helpers). -2. Build a command process with `tu.NewProcess(...)`. -3. Run commands against sandbox context/runtime. -4. Assert stdout/stderr and filesystem effects. - -This creates a close-to-real execution path without shelling out to external -processes. - -## Configurable Command Pipelines - -A single test usually runs multiple commands sequentially against the same -sandbox runtime, which acts like an in-memory workflow pipeline. - -Example sequence: - -1. `tap bootstrap ...` -2. `tap keg create ...` -3. `tap use ...` -4. `tap create ...` -5. `tap cat ...` - -Tests for legacy compatibility can cover hidden aliases separately. Current -user-facing flows should prefer `tap keg create`. - -Because each command runs through `cli.Run(...)`, tests exercise the same -command wiring and service resolution code used in real usage. - -## Fixture-Driven Coverage - -Fixtures under package test data directories provide: - -- known keg layouts -- known repo config files -- expected node/index contents - -This keeps tests reproducible and avoids fragile ad hoc setup logic. +Tapper Hub owns the production repository. Its unit suite exercises handler +and service behavior; its PostgreSQL suite exercises the real repository, +transactions, locks, snapshots, schemas, attachments, archives, and +concurrent mutation semantics. Cross-repository checks use the local `go.work` +link so Hub tests compile against the candidate Tapper tree. diff --git a/docs/backups-and-archives.md b/docs/backups-and-archives.md index 12dce7f1..1e3e568f 100644 --- a/docs/backups-and-archives.md +++ b/docs/backups-and-archives.md @@ -129,7 +129,7 @@ The manifest records export metadata and the list of included nodes: "source": "personal", "exported_at": "2026-03-14T10:00:00Z", "with_history": true, - "with_config": true, + "with_settings": true, "with_schemas": true, "schemas": ["decision", "task"], "nodes": [ diff --git a/docs/configuration/README.md b/docs/configuration/README.md index aec47276..0cb25eb3 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -7,7 +7,7 @@ Tapper uses three configuration layers: 1. User config (`~/.config/tapper/config.yaml`) 2. Project config (`.tapper/config.yaml`) -3. Keg config (`/keg`) +3. Keg settings stored by Tapper Hub User and project configs control target resolution. Keg config controls metadata inside a specific keg. @@ -23,9 +23,10 @@ inside a specific keg. The normal onboarding path is: ```bash -tap bootstrap --kind local --default-keg @local/personal -tap keg create @local/personal -tap use @local/personal --user +tap bootstrap --kind cloud +tap auth login +tap keg create personal +tap use personal --user ``` For a shared team setup, bootstrap a hosted or enterprise hub, authenticate, then @@ -42,13 +43,13 @@ tap use @acme/engineering - Need machine defaults, hubs, and credentials: [User Config](user-config.md) - Need repo-specific defaults for teammates: [Project Config](project-config.md) -- Need title/creator/links/indexes for a keg: [Keg Config](keg-config.md) +- Need title/creator/links/indexes for a keg: [Keg Settings](keg-settings.md) ## Detailed Pages - [User Config](user-config.md) - [Project Config](project-config.md) -- [Keg Config](keg-config.md) +- [Keg Settings](keg-settings.md) - [Keg Note Schemas](schemas.md) - [Resolution Order](resolution-order.md) - [Configuration Examples](examples.md) diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index b8d4cd5b..8e58104b 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -1,35 +1,34 @@ # Configuration Examples -These examples use the current config shape: hubs are a name-keyed map, each -with its own `defaultNamespace`, and local kegs live at -`/@/`. A keg is named by reference — a bare name, an -`@namespace/name` reference, or a path — there is no `kegs` alias map. +These examples use the current remote-only config shape. Hubs are a name-keyed +map, each with its own `defaultNamespace`. A KEG is named by a bare name or an +`@namespace/name` reference; there is no `kegs` alias map. -## Single Laptop Setup +## Hosted Cloud Setup ```yaml # ~/.config/tapper/config.yaml -fallbackHub: my-laptop -fallbackNamespace: local +fallbackHub: atlas +fallbackNamespace: me fallbackKeg: pub kegMap: [] hubs: - my-laptop: - kind: local - defaultNamespace: local - basePath: ~/Documents/kegs + atlas: + kind: remote + defaultNamespace: me + url: https://atlas.foldwise.ai + tokenEnv: ATLAS_API_KEY ``` -Use this when your local kegs live in one directory and no repo-specific -overrides are needed. A keg named `pub` resolves to -`~/Documents/kegs/@local/pub`. +Bootstrap normally writes this shape and adopts the authenticated user's home +namespace from the Hub. ## Multi-Repo Setup With `kegMap` ```yaml # ~/.config/tapper/config.yaml -fallbackHub: my-laptop -fallbackNamespace: local +fallbackHub: atlas +fallbackNamespace: me fallbackKeg: pub kegMap: - alias: pub @@ -37,15 +36,16 @@ kegMap: - alias: work pathPrefix: ~/repos/github.com/work hubs: - my-laptop: - kind: local - defaultNamespace: local - basePath: ~/Documents/kegs + atlas: + kind: remote + defaultNamespace: me + url: https://atlas.foldwise.ai + tokenEnv: ATLAS_API_KEY ``` This routes different repo roots to different kegs. Each `alias` is a keg -reference (here the bare names `pub` and `work`, which resolve to -`@local/pub` and `@local/work`). +reference (here the bare names `pub` and `work`, which select remote KEGs based +on workspace path). ## Project Override Setup @@ -53,15 +53,13 @@ reference (here the bare names `pub` and `work`, which resolve to # .tapper/config.yaml defaultKeg: tapper fallbackKeg: tapper -defaultHub: my-laptop -defaultNamespace: local +defaultHub: atlas +defaultNamespace: acme kegMap: [] ``` -This makes the repository default to the `tapper` keg on the local hub -(`/@local/tapper`): `defaultKeg: tapper` resolves its namespace from -`defaultNamespace: local`, and the local namespace selects the local hub. Hubs -and credentials cannot be set here — only in user config. +This makes the repository default to `keg:@acme/tapper` on the configured +`atlas` Hub. Hubs and credentials cannot be set here — only in user config. ## Hub-Oriented Setup @@ -79,8 +77,7 @@ hubs: tokenEnv: KNUT_API_KEY ``` -Use this when references should resolve to API-style hub targets instead of -local file paths. `fallbackKeg: public` resolves its namespace from +Use this for an enterprise Hub. `fallbackKeg: public` resolves its namespace from `fallbackNamespace: me` and its hub from that namespace, yielding `keg:@me/public` on the `knut` hub. @@ -88,21 +85,21 @@ local file paths. `fallbackKeg: public` resolves its namespace from ```yaml # ~/.config/tapper/config.yaml -fallbackHub: my-laptop -fallbackNamespace: local -fallbackKeg: local +fallbackHub: enterprise +fallbackNamespace: acme +fallbackKeg: private disableAtlasHub: true hubs: - my-laptop: - kind: local - defaultNamespace: local - basePath: ~/Documents/kegs + enterprise: + kind: remote + defaultNamespace: acme + url: https://tapper.acme.internal + tokenEnv: TAPPER_ENTERPRISE_TOKEN ``` -Use this when the deployment must prove no implicit network calls happen. With -`disableAtlasHub: true` and no remote `hubs` entries, hub-dependent commands -error with `no hub configured; implicit atlas hub disabled` instead of silently -reaching `https://atlas.foldwise.ai`. +Use this when the deployment must prove Tapper never contacts the compiled-in +Atlas endpoint. With `disableAtlasHub: true`, resolution stays on explicitly +configured enterprise Hubs. ## Generating A Config @@ -110,15 +107,11 @@ Rather than write any of the above by hand, run `tap bootstrap`: ```bash tap bootstrap # cloud (atlas) — the default -tap bootstrap --kind local # local hub only tap bootstrap --kind enterprise --endpoint keg.acme.com ``` -Bootstrap writes `fallbackHub`, the built-in local hub (keyed by the machine -hostname), and the `local → ` namespace mapping, then asks for a -default keg and records it as `fallbackKeg` so plain `tap` commands resolve one -immediately (a project's `defaultKeg` or `kegMap` still overrides it). It does -not write a global `fallbackNamespace`: the namespace comes from the resolved -hub's own `defaultNamespace` field (adopted from the hub at login for -cloud/enterprise). +Bootstrap writes `fallbackHub`, then asks for a default KEG and records it as +`fallbackKeg` so plain `tap` commands resolve one immediately (a project's +`defaultKeg` or `kegMap` still overrides it). The namespace comes from the +resolved Hub's own `defaultNamespace`, adopted from whoami at login. See [User Config](user-config.md#tap-bootstrap). diff --git a/docs/configuration/flights.md b/docs/configuration/flights.md index d9e0fe81..76bd1b0e 100644 --- a/docs/configuration/flights.md +++ b/docs/configuration/flights.md @@ -4,12 +4,12 @@ A **flight** is the required authorization and instruction context for an MCP session. Flight manifests live separately from Tapper configuration. `tap bootstrap` can persist a machine-wide baseline in the user config, while a project can persist a more specific selection in `.tapper/config.yaml`. The -server resolves fresh orientation during MCP initialization and again whenever -the client explicitly calls `orient`. +server pins the root reference during MCP initialization, then resolves its +live graph and the requested flight before every authority-bearing tool call. ## What A Flight Does -A flight carries four details: +A flight carries five details: 1. **A keg cover** (`cover`). MCP tools reject kegs outside the cover. Each cover entry has a `viewer`, `editor`, or `admin` cap. Reads require @@ -25,12 +25,22 @@ A flight carries four details: created or updated. 4. **Capabilities** (`capabilities`). `full_access` supplies admin-class flight authority across every KEG the authenticated identity can already access, - while normal local and Hub authorization still applies. It never raises the + while normal Hub authorization still applies. It never raises the identity's actual KEG role. `manage_flights` exposes flight mutation tools to the session, but Hub still requires the authenticated identity to own or administer the target namespace. `manage_kegs` exposes `keg_create`, and Hub still requires the identity to belong to the target namespace. The capabilities are independent. +5. **Ordered direct child entries** (`subflights`). Each flight may list up to + 64 canonical children. Runtime flattening is ordered breadth-first, emits a + shared descendant once, tolerates cycles by deduplicating already loaded and + expanded flights, and retains the deterministic shortest selection path. + There is no depth-eight rule. A pinned root may expose at most 256 unique + accessible descendants at runtime; exceeding that cap refuses the call. + A selected descendant supplies only its own instructions, capabilities, and + cover and may be broader or different from its ancestors. Cross-Hub + relations and duplicate canonical children are rejected; referenced + children cannot be deleted. Because a flight is not a KEG target selector, `tap mcp --flight` binds only the process flight identity. `tap mcp --keg` remains an independent default for @@ -39,24 +49,18 @@ subsequent KEG operations. `tap orient` is flight-scoped and rejects ## Manifest Format -Local flights live beside the `@` directories of the local hub, in a -reserved `flights.d` directory: - -```text -/flights.d/.yaml -``` - -(`flights.d` is deliberately not a legal namespace — it contains a dot — so it -can never collide with a keg path.) The file stem is the flight name. Each -manifest has five optional fields: +Flights are stored by Tapper Hub and addressed as `@namespace/+slug`. Each +manifest has six optional fields: ```yaml -# /flights.d/release-42.yaml title: Release 42 cut visibility: private capabilities: - full_access - manage_flights +subflights: + - "@acme/+release-notes" + - "@acme/+verification" cover: - namespace: acme keg: release-notes @@ -73,13 +77,11 @@ A cover entry without an explicit `role` defaults to `viewer` — the same default applies to `--cover` specs on the CLI and MCP surfaces; `editor` and `admin` must be requested explicitly. Unknown roles are rejected. -Older local manifests that use `allowedKegs` still load; each bare entry is -treated as an `editor` cover row for backward compatibility, while an entry +Older Hub manifests that use `allowedKegs` still load; each bare entry is +treated as an `editor` cover row for wire compatibility, while an entry with an explicit `=viewer` suffix keeps its viewer cap. -Remote flights are served by Hub and addressed canonically as -`@namespace/+slug`. `tap flight create/edit/delete` manage Hub-backed flights; -local `flights.d` manifests remain read-only files. +`tap flight create/edit/delete` manage Hub-backed flights exclusively. ## Commands @@ -100,109 +102,103 @@ local `flights.d` manifests remain read-only files. first line is a `yaml-language-server` schema modeline for `schemas/flight-manifest.json`, followed by a short comment that the `@namespace/+slug` ref is immutable. The editable fields are `title`, -`visibility`, `capabilities`, `cover`, and `instructions`; comments and the +`visibility`, `capabilities`, `subflights`, `cover`, and `instructions`; comments and the modeline are ignored when deciding whether the manifest changed. -MCP always exposes `list_flights` and `flight_show`. It exposes -`flight_create`, `flight_edit`, and `flight_delete` only while the session's -active flight grants `manage_flights`; direct calls are checked server-side as -well. `flight_edit` is a partial update where omitted fields retain their -current values. A Hub-backed active flight may edit or delete itself: - -- a successful self-edit immediately adopts the exact returned manifest, - cover, instructions, and capabilities before the response is released; -- removing `manage_flights` therefore removes the mutation tools immediately; -- a successful self-delete immediately enters recovery-only mode; -- editing or deleting another flight does not change current session authority. - -Local `flights.d` manifests remain MCP read-only — *reading* them is fully -supported (discovery, orientation, and cover enforcement all work off -`flights.d`), but create/update/delete is not implemented for local hubs and -refuses with a message naming the manifest path to write instead. Flight -mutations always use normal Hub authorization in addition to the active flight -capability. +MCP always exposes `list_flights` and `flight_show`. In an active session it +also keeps `flight_create`, `flight_edit`, `flight_delete`, and `keg_create` +visible because a selectable descendant may grant their capability even when +the root does not. Dispatch checks `manage_flights` or `manage_kegs` against +the flight selected for that call, then applies normal Hub authorization. +`flight_edit` is a partial update where omitted fields retain their current +values. Call `flight_show` first and pass its manifest hash as the required +`expected_hash` for both edits and deletes. On conflict, merge or refetch and +retry with the returned current hash. Graph and authority edits are adopted on +the next call automatically. Mutations are never replayed. A referenced +subflight cannot be deleted. + +Flight mutations always use normal Hub authorization in addition to the +selected flight capability. ## Behavior -- MCP tools reject a keg outside the active flight's cover +- MCP tools reject a keg outside the call-selected flight's cover with a "keg … is not available in flight …" error. - MCP writes against a `viewer` cover row are rejected as viewer-only. -- Every cover and role-cap denial closes by telling the agent to call `orient`. - A session pins its flight snapshot until it re-orients, so a flight edited - elsewhere mid-session is the usual reason a call the agent expected to - succeed is refused, and the refusal alone cannot reveal that. Hosted `/mcp` - appends the same instruction when a Hub grant — rather than the cover — - is what denies the keg. +- A fresh selection, cover, capability, or role refusal reports + `ORIENTATION_DENIED`, `reorientRequired=false`, and + `operationPerformed=false`. A change racing between call resolution and Hub + validation reports `ORIENTATION_STALE`; transient graph or identity failures report + `ORIENTATION_UNAVAILABLE`; permanent loss of the connection-pinned root reports + `ORIENTATION_ROOT_UNAVAILABLE` and requires a new session. - `keg_settings_edit` replaces the complete validated KEG YAML document and requires an `admin` cover (or `full_access`) plus editor/admin identity access - to that KEG. An admin flight cap never creates a Hub admin identity. + to that KEG. Read the full document with `keg_settings` and pass its hash as + the required `expected_hash`; merge or refetch after conflicts and retry with + the returned current hash. An admin flight cap never creates a Hub admin identity. - `full_access` permits admin-class flight operations outside the cover, but does not bypass normal identity authorization or implicitly grant `manage_flights`. -- Without a selected flight, MCP starts in recovery-only mode and lists only - `orient`, `list_flights`, `flight_show`, and credential-safe `auth_info`. - After selecting a flight outside MCP, call `orient` on the same connection. -- When the session can reach **no flights at all**, it instead runs on a - synthetic **bootstrap flight**. Selecting from an empty list is not a - recovery, so the session is given the authority to populate it: the cover is - empty (every KEG tool stays locked) and the capabilities are `manage_flights` - plus `manage_kegs`, so `flight_create`, `flight_edit`, `flight_delete`, and - `keg_create` join the recovery four. The flight is never persisted, and its - instructions name the surface that owns selection for that transport — `tap` - configuration for stdio, the account page for hosted `/mcp`. -- Creating a flight from bootstrap does not select it. The next `orient` sees a - reachable flight and moves the session to recovery-only mode, where "select - one" has become the actionable step. -- On a local-only setup `flight_create` still fails: flight mutation is not - implemented for local hubs (see below). The bootstrap instructions say so and - point at the manifest path to write by hand. -- Config-driven `tap mcp` reloads user, project, and environment configuration - on every orientation. A successful orientation atomically replaces session - authority; configuration changes alone do nothing. -- `tap mcp --flight REF` is launcher-bound: configuration cannot change its - flight identity, while orientation still refreshes that flight's current - manifest, cover, and instructions. -- A failed refresh preserves the last valid authority. An intentionally blank - config selection clears authority and enters recovery mode. -- If a self-edit is persisted but exact orientation rendering fails, the tool - reports that the update was applied and enters recovery instead of retaining - stale authority. -- Hosted `/mcp` selects the account-wide MCP flight preference. Local - initialization and `orient` select explicit `--flight`, then `TAP_FLIGHT`, - then the active agent's `flight`, then the nearest project config, and finally - the user baseline. -- Hosted self-deletion clears the account preference through the flight foreign - key. A local config that still names a deleted flight remains a stale external - reference: later `orient` reports it and the session stays in recovery until - configuration is changed outside MCP. -- In-flight calls finish under the context captured when they began. Calls that - start after orientation use the newly published context. +- Without a selected flight, MCP publishes the complete tool inventory and bare + calls use normal identity-authorized full access. Every accessible KEG appears + at the caller's real role; this never raises Hub ACLs or namespace membership. + An explicit `flight` selects any listed identity-accessible real flight for + that call and uses only its cover, capabilities, and instructions. +- No-flight authority is pinned for the connection lifetime. Creating a KEG or + flight does not replace it, and `session_refresh` returns `already_active` + with `nextAction:"new_session"`. Create a least-privilege flight, pin it + outside MCP, and start a new connection to narrow access. Newly created + flights are immediately available for explicit call-local selection. +- Recovery-only mode applies only when an explicitly configured root is + missing, inaccessible, invalid, or unavailable. Seeing only `orient`, + `session_refresh`, `list_flights`, `flight_show`, `auth_info`, and + `keg_search` 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 + that state mid-session. Every + authority-bearing tool accepts an optional top-level `flight`; omission uses + identity authority in no-flight state or the real root otherwise. From + no-flight state, an explicit value may name any listed real flight; from a + real root, it may name only that root or an accessible transitive descendant. + The selected flight's authority is never inherited or combined. +- Graph, cover, capability, role, relation, and identity changes are loaded on + the next call without an explicit refresh. A transient load failure refuses + that call with `ORIENTATION_UNAVAILABLE`; it never falls back to cached + authority. A remote resolution obtains the accessible manifests from one + fresh `GET /api/v1/flights` response and performs canonical lookup and + bounded flattening locally. +- Hosted `/mcp` uses the account-wide MCP flight preference only at connection + initialization. Stdio initialization selects explicit `--flight`, then + `TAP_FLIGHT`, then the nearest project config, and finally the user baseline. +- Hosted deletion of the launch root clears the account preference through the + flight foreign key, but cannot replace the root of an existing connection. + That connection reports `ORIENTATION_ROOT_UNAVAILABLE`; a new launch is + required. A local config that still names a deleted flight remains an + external stale reference until configuration is changed outside MCP. +- Each in-flight call uses its own immutable orientation context. Concurrent + root, child, sibling, and grandchild calls cannot change one another's + selection. MCP resources have no `flight` parameter and use the pinned root. - Direct CLI commands such as `tap cat`, `tap edit`, and `tap create` ignore flight cover caps; access is governed by normal keg authorization. -- A missing `flights.d` directory means "no flights", not an error. - `tap orient --flight @namespace/+slug` injects the flight's title, available kegs, and instructions into the orientation payload. - `tap use --flight @namespace/+slug` persists the project default in `.tapper/config.yaml`; `tap use +slug` uses the resolved default namespace. - Config-driven sessions adopt it on their next orientation. + A connection that started with no flight remains fully authorized until it + ends; the new selection takes effect only on a new connection. - Flight selection precedence is explicit runtime `--flight`, then - `TAP_FLIGHT`, then the active agent's `flight`, then the nearest project - config, then the user baseline written by `tap bootstrap`. Project selection + `TAP_FLIGHT`, then the nearest project config, then the user baseline written + by `tap bootstrap`. Project selection therefore overrides the machine-wide bootstrap choice without changing it. -- `tap launch --agent NAME` exports `TAP_AGENT=NAME`, not the flight that agent - currently names. The launched session resolves `agents[NAME].flight` on every - orientation, so editing that agent's flight and calling `orient` again moves - the running session. A resolved flight in the environment could not be - changed after launch, since a process cannot alter its own environment. - `TAP_FLIGHT` and `--flight` are direct and still outrank the agent, so either - one pins a launched session to a flight of its own. -- A `TAP_AGENT` naming an agent that is not configured is reported as a warning - in the orientation payload and the flight falls back to project and user - configuration. It is not fatal: a stale agent name is not something a session - can fix from the inside. -- MCP tools have no model-visible `flight` input. Humans change config-driven - selection with `tap use --flight @namespace/+slug` (or `tap use +slug`), then - the existing session calls `orient`. There is no hidden flight-switch tool. +- `tap launch --agent NAME` uses the agent only for model selection and + telemetry. Launch requires a Hub-backed root and exports its canonical + reference once as `TAP_FLIGHT`. Legacy `agents[NAME].flight` + values are ignored. +- MCP tools have no model-visible root-switch input. Their optional `flight` + makes only a call-local selection. To change the connection's default + authority, the user starts a new session after changing configuration; + there is no hidden root-switch tool. - KEG-specific instructions belong in each KEG's own config `instructions` field, not in flight cover rows. - Tapper user/project configuration and hosted flight selection remain diff --git a/docs/configuration/keg-config.md b/docs/configuration/keg-settings.md similarity index 94% rename from docs/configuration/keg-config.md rename to docs/configuration/keg-settings.md index 47921c01..a1c3fda1 100644 --- a/docs/configuration/keg-config.md +++ b/docs/configuration/keg-settings.md @@ -1,4 +1,4 @@ -# Keg Config +# Keg Settings Keg config is metadata stored in a keg repository itself. @@ -57,7 +57,7 @@ resolved mode. Strict does not scan existing nodes when enabled and does not prevent schema replacement or deletion because of stored nodes. Node 0, imports, archive -restores, snapshot restores, and schema/config operations are exempt from the +restores, snapshot restores, and schema/settings operations are exempt from the selection rule. Newly initialized KEGs still set `strict: true`; older configs with no `strict` field remain non-strict. @@ -65,7 +65,7 @@ with no `strict` field remain non-strict. - Edit user config for machine defaults, hubs, and credentials. - Edit project config for repo-specific resolution behavior. -- Edit keg config for keg metadata and index/link declarations. +- Edit keg settings for keg metadata and index/link declarations. ## Validation And Safe Editing Tips diff --git a/docs/configuration/project-config.md b/docs/configuration/project-config.md index 8a5d9db5..b42253bc 100644 --- a/docs/configuration/project-config.md +++ b/docs/configuration/project-config.md @@ -57,8 +57,8 @@ target or harvest a token environment variable. See - Commit `.tapper/config.yaml` with the repository's shared `defaultKeg` and, when needed, `defaultNamespace`. -- Prefer a shared hub keg for team memory. Use a local project keg only when the - knowledge should live with the repository. +- Use a shared Hub KEG for team memory; `kegMap` can select different remote + KEGs for different workspace paths. - Use user config for personal/global hubs, credentials, and fallbacks. ## Minimal Project Config Example diff --git a/docs/configuration/resolution-order.md b/docs/configuration/resolution-order.md index 395b7c77..a7e65cfa 100644 --- a/docs/configuration/resolution-order.md +++ b/docs/configuration/resolution-order.md @@ -15,13 +15,13 @@ If you pass explicit flags, they take precedence: `--flight` is not a keg selector. It is flight context for orient/MCP: agent instructions plus cover caps enforced by the MCP surface. Direct CLI commands still use normal keg authorization and do not have access reduced by the flight. -Its precedence follows the config cascade: explicit `--flight`, `TAP_FLIGHT`, -the active agent's `flight`, the nearest project `flight`, then the user -baseline optionally written by `tap bootstrap`. See [Flights](flights.md). +Its precedence at session initialization is explicit `--flight`, `TAP_FLIGHT`, +the nearest project `flight`, then the user baseline optionally written by +`tap bootstrap`. `TAP_AGENT` never selects a flight. The resulting root +reference cannot change within that MCP connection. See [Flights](flights.md). -The local creation flags on `tap keg create` (`--project`, `--cwd`, and -`--path`) only choose where a new filesystem keg is created. They are not -general targeting flags on the full `tap` surface. +Filesystem paths, `file://` targets, and the removed local-creation flags are +unsupported. `tap keg create` always calls a configured Hub. ## 2. No Explicit Keg Flow @@ -34,10 +34,10 @@ order: ## 3. Namespace-centric model -Resolution flows **keg name -> namespace -> hub -> backend**. A keg is identified +Resolution flows **keg name -> namespace -> Hub**. A keg is identified by `@/`; the namespace determines which hub hosts it. A keg selector (`defaultKeg`, `fallbackKeg`, `--keg`, a `kegMap` alias) is a keg -reference — a bare name, `@namespace/name`, `keg:@namespace/name`, or a path — +reference — a bare name, `@namespace/name`, or `keg:@namespace/name` — there is no `kegs` alias map. One config map disambiguates the namespace→hub hop: - **`namespaces`** maps a namespace to the hub that hosts it — the conflict @@ -51,9 +51,8 @@ An omitted `namespace` is resolved **first**, in this order: 1. explicit `namespace` on the reference 2. `defaultNamespace` (high-precedence slot — set in project config) 3. `fallbackNamespace` (last-resort slot — set in user config) -4. once the hub is known: the hub's own `namespace` default, then the reserved - `local` namespace for a local hub; a remote hub with nothing resolved is an - error +4. once the Hub is known: the Hub's own namespace default; if nothing resolves, + the reference is an error Namespaces must be a single portable path segment (`[a-z0-9_-]+`, no dots or slashes). @@ -64,36 +63,30 @@ The hosting hub is resolved **from the namespace**, in this order: 1. explicit `hub` on the reference 2. `namespaces[ns].hub` (the namespace → hub map) -3. the reserved `local` namespace pins this machine's local (filesystem) hub -4. `defaultHub` (high-precedence slot — set in project config) -5. `fallbackHub` (last-resort slot — set in user config) -6. the sole configured hub (or the alphabetically-first when several exist) -7. the compiled-in `atlas` remote hub (`https://atlas.foldwise.ai`) +3. `defaultHub` (high-precedence slot — set in project config) +4. `fallbackHub` (last-resort slot — set in user config) +5. the sole configured Hub (or the alphabetically-first when several exist) +6. the compiled-in `atlas` remote Hub (`https://atlas.foldwise.ai`) Setting `disableAtlasHub: true` (or `TAP_DISABLE_ATLAS_HUB=1`) removes step -7: hub-dependent commands then fail with a clear error instead of silently -reaching the compiled-in default. `disableLocalHub` likewise suppresses the -synthesized built-in local hub. +6: Hub-dependent commands then fail with a clear error instead of silently +reaching the compiled-in default. -## 6. Keg references and on-disk layout +## 6. KEG references and Hub routes A keg reference is the `keg` scheme — `keg:@/` (the namespace is optional: `keg:`). The hub is **not** part of the reference; it is resolved from the namespace via the chains above. A node within a keg appends the node id: `keg:@//`. -A local-hub keg resolves to a file target on disk at: - -```text -/@/ -``` - -The `@` sigil is part of the directory name. The reserved `@local` namespace -addresses this machine's local hub. Remote and read-only hubs resolve to -`/api/v1/@/kegs/` instead (namespace first; only the +Remote and read-only Hubs resolve to +`/api/v1/@/kegs/` (namespace first; only the namespace segment carries the `@` sigil — keg aliases are bare in the tapper-hub route layout). +`@local` is not reserved. It behaves like any other namespace if a remote Hub +hosts it. + ## 7. Config Cascade The effective config is assembled from several layers, most specific winning: @@ -125,8 +118,8 @@ the `default*` / `fallback*` selectors. `tap info` resolves `tapper` first. - If `defaultKeg` is empty and `kegMap` matches the current path to alias `work`, `tap info` resolves `work`. -- A reference `{name: notes}` with no hub and no namespace, under a user config - whose `fallbackHub` points at a local hub with `defaultNamespace: local`, resolves to - `/@local/notes`. +- A reference `{name: notes}` with no Hub and no namespace, under a user config + whose `fallbackHub` has `defaultNamespace: acme`, resolves to + `keg:@acme/notes` on that Hub. - A project config that sets `defaultNamespace: acme` makes the same reference resolve under `@acme` instead, overriding the user-level fallback. diff --git a/docs/configuration/troubleshooting.md b/docs/configuration/troubleshooting.md index 9c69bb4c..6659f60a 100644 --- a/docs/configuration/troubleshooting.md +++ b/docs/configuration/troubleshooting.md @@ -25,7 +25,7 @@ Cause: Fix: - Set `defaultKeg`/`fallbackKeg` to a resolvable reference: a bare name plus a - `fallbackNamespace`, an explicit `@namespace/name`, or a path. + `fallbackNamespace`, or an explicit `@namespace/name`. - Verify the reference in `defaultKeg`, `fallbackKeg`, and `kegMap` entries, and that the namespace routes to a hub via `defaultHub`/`namespaces`. - Run `tap keg list` to see the kegs the configured hubs actually expose. @@ -43,8 +43,8 @@ Fix: - Set the hub's own `namespace` (its default), or - Set `defaultNamespace` (project) / `fallbackNamespace` (user). -Local-hub references do not hit this — they fall back to the reserved `@local` -namespace. +Filesystem paths and `file://` targets are unsupported; configure the remote +Hub and namespace that host the KEG. ## "ignored hubs … in project config" @@ -79,8 +79,8 @@ Fix: Cause: -- A reference's resolved hub name is not present in `hubs` and is not a built-in - (`local`, `atlas`). +- A reference's resolved Hub name is not present in `hubs` and is not the + built-in `atlas` Hub. Fix: @@ -101,7 +101,7 @@ tap config --project tap config --explain defaultKeg tap config --show-sources -# Show active keg config (resolved target) +# Show active keg settings (resolved target) tap keg settings # Confirm resolution for a specific keg diff --git a/docs/configuration/user-config.md b/docs/configuration/user-config.md index 0475c6fa..66d9fa43 100644 --- a/docs/configuration/user-config.md +++ b/docs/configuration/user-config.md @@ -19,20 +19,18 @@ cat config.yaml | tap config edit --user ``` The fastest way to create a sensible starting config is `tap bootstrap`, which -writes the fallback hub and the built-in local hub for you (see below). +writes a cloud or enterprise fallback Hub (see below). > **First run requires `tap bootstrap`.** On the full `tap` surface, > hub/namespace-dependent commands (`tap keg create `, `tap cat`, > `tap list`, …) refuse with a clear error until this user config exists — they -> no longer silently create or resolve a keg in a hidden platform directory. -> Explicit local destinations (`tap keg create --project` / `--path`) still work -> without setup, as does the pruned `keg` binary. +> no longer silently create or resolve a KEG on local storage. ## Key Reference - `fallbackKeg`: last-resort keg reference when no default/map match resolves - `defaultKeg`: optional keg reference used first when no keg flag is provided. - A reference is a bare name, `@namespace/name`, `keg:@namespace/name`, or a path + A reference is a bare name, `@namespace/name`, or `keg:@namespace/name` — resolved through the namespace-centric chain (there is no `kegs` alias map). - `namespaces`: map of namespace → hosting hub (`namespaces[ns].hub`, or the scalar shorthand `ns: hub`). Role: disambiguate @@ -48,41 +46,34 @@ writes the fallback hub and the built-in local hub for you (see below). - `defaultHub` / `defaultNamespace`: high-precedence slots. Usually set in project config rather than here; they make `tap keg create example` equivalent to `@/example`. -- `disableAtlasHub` / `disableLocalHub`: when `true`, suppress the synthesized - built-in atlas / local hub. A disabled built-in is not synthesized, is omitted +- `disableAtlasHub`: when `true`, suppress the synthesized built-in atlas Hub. + A disabled built-in is not synthesized, is omitted from hub listings, and is skipped in resolution; an explicit `hubs` entry of the same name is unaffected. `disableAtlasHub` is useful for SOC2-audited deployments that must prove no implicit network targets exist. - `disableTelemetry`: when `true`, disables privacy-minimized CLI and MCP invocation reporting. `TAP_DISABLE_TELEMETRY=1` is the environment opt-out. -- `hubs`: name-keyed map of hub definitions (`kind`, `defaultNamespace`, `url`, - `basePath`, `token`/`tokenEnv`). **User config only** — see the trust boundary +- `hubs`: name-keyed map of Hub definitions (`kind`, `defaultNamespace`, `url`, + `token`/`tokenEnv`). **User config only** — see the trust boundary below. -> Note: `kegSearchPaths` is not a recognized key. A config that carries it is -> parsed but the key is ignored (dropped on the next re-serialize). There is no -> `TAP_KEG_SEARCH_PATHS` env var. +Tapper configuration is extensible. Unknown top-level fields and unknown fields +inside hubs, namespaces, agents, and kegMap entries load without warnings and +survive Tapper-driven rewrites. A known-field update changes only Tapper-owned +values; explicitly removing an object removes that complete object. ## Hubs -Hubs are a name-keyed map. Each entry's `defaultNamespace` field is that hub's +Hubs are a name-keyed map. Each entry's `defaultNamespace` field is that Hub's **default** namespace — a hub hosts many namespaces; this is only the one used -when a reference resolved against the hub omits its own. Two built-ins are -synthesized when not configured explicitly (and not disabled via -`disableAtlasHub` / `disableLocalHub`): - -- `local` — the built-in filesystem hub (kind `local`) -- `atlas` — the compiled-in default remote hub (`https://atlas.foldwise.ai`) +when a reference resolved against the Hub omits its own. The `atlas` remote Hub +is synthesized when not configured explicitly and not disabled with +`disableAtlasHub`. An explicit entry always overrides the synthesized built-in. ```yaml hubs: - # the machine's local filesystem hub, keyed by hostname (written by `tap bootstrap`) - my-laptop: - kind: local - defaultNamespace: local # the reserved @local namespace - basePath: ~/Documents/kegs atlas: kind: remote defaultNamespace: me @@ -90,9 +81,8 @@ hubs: tokenEnv: ATLAS_API_KEY ``` -A local-hub keg lives on disk at `/@/` — the `@` -sigil is part of the directory name. The reserved `@local` namespace addresses -this machine's local hub. +Supported kinds are `remote` and `readonly`. A namespace named `local` has no +special meaning; it resolves like any other namespace when a remote Hub hosts it. ### Trust boundary @@ -116,8 +106,8 @@ credentials, and MCP session identifiers are never uploaded. Events go only to `/api/v1/telemetry/invocations` on the authenticated remote Hub selected by the user config's login-hub default/fallback chain, using the existing AuthStore token. Tap silently skips reporting when it is not -bootstrapped, not authenticated, configured only for a local Hub, or connected -to a Hub version without the endpoint. The Hub writes accepted events to its +bootstrapped, not authenticated, or connected to a Hub version without the +endpoint. The Hub writes accepted events to its structured logs rather than PostgreSQL; Atlas currently inherits the standard 30-day Loki retention. @@ -138,25 +128,19 @@ export TAP_DISABLE_TELEMETRY=1 `tap bootstrap` materializes or refreshes this user config around a deployment kind: -- `local` — only the built-in local filesystem hub - `cloud` (default) — the compiled-in `atlas` remote hub - `enterprise --endpoint ` — a user-supplied remote HTTP hub -It always writes a local hub **keyed by the machine hostname** with -`defaultNamespace: local` (the reserved `@local`), plus the remote hub for -cloud/enterprise. It writes the **fallback** hub (`fallbackHub`), not the +It writes the selected **fallback** Hub (`fallbackHub`), not the default slot — the project config owns the high-precedence `default*` slots. It does **not** write a global `fallbackNamespace` or a per-user `namespaces` -entry. The preferred namespace comes from the resolved hub's own -`defaultNamespace` field: `@local` for the local hub, and your home namespace -for cloud/enterprise -(left empty until `tap auth login` adopts it from the hub's whoami probe). The -only `namespaces` entry written is `local → `, pinning `@local` to -this machine. - -It is idempotent: re-running only touches the fallback hub, the local namespace -mapping, and the kind's hub entry, leaving your `kegMap` and any +entry. The preferred namespace comes from the resolved Hub's own +`defaultNamespace` field and is left empty until `tap auth login` adopts the +home namespace from the Hub's whoami probe. + +It is idempotent: re-running only touches the fallback Hub and its entry, +leaving extension fields, `kegMap`, and any `fallbackNamespace` you set by hand untouched. It also asks for a default keg and records it as `fallbackKeg` (the global-user slot) so plain `tap` commands resolve one after setup, while a project's `defaultKeg` or a `kegMap` rule can @@ -166,11 +150,12 @@ Interactive bootstrap also discovers flights from only the selected hub and offers to store one as the user-level `flight` baseline. Existing baselines are preselected when available, and **Skip for now** leaves the current value unchanged. For scripts, pass the inherited global flag explicitly, for example -`tap bootstrap --kind local --flight @local/+focused`; bootstrap validates the +`tap bootstrap --kind cloud --flight @team/+focused`; bootstrap validates the flight and stores its canonical `@namespace/+slug` reference. If no baseline is -set, MCP starts in recovery-only mode. A project `flight`, the active agent's -`flight`, `TAP_FLIGHT`, or an explicit `--flight` on a later command overrides -the bootstrap baseline. +set, MCP starts with identity-authorized full access; pin a least-privilege +flight and start a new MCP connection to narrow it. A project `flight`, +`TAP_FLIGHT`, or an explicit `--flight` on a later command overrides the +bootstrap baseline. Agent entries select models only and never select flights. ## Hub Resolution Chain @@ -187,33 +172,29 @@ order, stopping at the first match: ## Recommended Baseline Config ```yaml -fallbackHub: my-laptop -fallbackNamespace: local +fallbackHub: atlas +fallbackNamespace: me fallbackKeg: pub kegMap: - alias: pub pathPrefix: ~/repos/github.com -namespaces: - # which hub hosts each namespace (the namespace→hub conflict resolver) - local: my-laptop # scalar shorthand for {hub: my-laptop} hubs: - my-laptop: - kind: local - defaultNamespace: local - basePath: ~/Documents/kegs + atlas: + kind: remote + defaultNamespace: me + url: https://atlas.foldwise.ai + tokenEnv: ATLAS_API_KEY ``` -Here `fallbackKeg: pub` and the `kegMap` alias `pub` are both keg references — -bare name `pub`, resolved via `fallbackNamespace: local` to `@local/pub` at -`~/Documents/kegs/@local/pub`. +Here `fallbackKeg: pub` and the `kegMap` alias `pub` are both remote KEG +references, resolved through the configured namespace and Hub. ## Common Mistakes - Unresolvable reference: `defaultKeg`, `fallbackKeg`, or `kegMap.alias` is a bare name with no `defaultNamespace`/`fallbackNamespace`, or names a keg that does not exist on the resolved hub. -- No namespace resolvable: a remote-hub reference with no explicit, per-hub, - default, or fallback namespace errors out. Local-hub references fall back to - `@local`. +- No namespace resolvable: a Hub reference with no explicit, per-hub, default, + or fallback namespace errors out. - Missing fallback: no `defaultKeg` plus no `fallbackKeg` can produce `no keg configured`. diff --git a/docs/keg-structure/domain-separation-and-migration.md b/docs/keg-structure/domain-separation-and-migration.md index 3d03df2e..753d461b 100644 --- a/docs/keg-structure/domain-separation-and-migration.md +++ b/docs/keg-structure/domain-separation-and-migration.md @@ -18,7 +18,7 @@ with minimal interaction. - `@acme/general`: broad cross-domain organization notes - `@acme/engineering`: project, architecture, release, and incident memory - `@acme/product`: customer, roadmap, and product decision memory -- `@local/private`: personal notes that should not be shared +- `@me/private`: personal notes in a private Hub namespace - `@acme/domain-x`: a dedicated specialized knowledge base ## Migration Plan: Move A Low-Overlap Domain Out Of A General Keg @@ -38,7 +38,7 @@ Create a destination keg target: tap keg create @acme/domain-x ``` -Inspect and edit new keg config: +Inspect and edit new keg settings: ```bash tap keg settings --keg @acme/domain-x diff --git a/docs/keg-structure/example-structures.md b/docs/keg-structure/example-structures.md index 2d4d57a0..b14151d0 100644 --- a/docs/keg-structure/example-structures.md +++ b/docs/keg-structure/example-structures.md @@ -96,31 +96,7 @@ my-project/ config.yaml ``` -The shared keg resolves through `@acme/tapper`. If the hub is local, its on-disk -layout is still the standard KEG layout: - -```text -/@acme/tapper/ - keg - dex/ - nodes.tsv - changes.md - links - backlinks - tags - 0/ - README.md - meta.yaml - stats.json - 100/ - README.md - meta.yaml - stats.json - 101/ - README.md - meta.yaml - stats.json -``` +The shared KEG resolves through `@acme/tapper` to its configured remote Hub. ### Example Project Config @@ -209,9 +185,8 @@ Interlinking is a core KEG behavior. Notes should be atomic and linked explicitl - `keg:/[-]` — the name resolves via the current keg's Links table, then as a keg-name reference through the namespace-centric chain (for example `keg:pub/921`). - - `keg:@//[-]` — fully qualified; the hub is implied - from the current keg's hub, and `@local` pins the local hub (for example - `keg:@me/public/921`). + - `keg:@//[-]` — fully qualified; the Hub is implied + by the namespace (for example `keg:@me/public/921`). ## Notes diff --git a/docs/keg-structure/markdown-style-guide.md b/docs/keg-structure/markdown-style-guide.md index 35da7240..9b82657c 100644 --- a/docs/keg-structure/markdown-style-guide.md +++ b/docs/keg-structure/markdown-style-guide.md @@ -58,8 +58,11 @@ The lead paragraph should: Interlinking is a core KEG behavior. -- Use relative links for local nodes: `../42` -- Use cross-KEG links when referencing outside the current keg: `keg:pub/921` +- Use Markdown links for local nodes: `[title](../42)` +- Use Markdown links when referencing outside the current keg: + `[title](keg:pub/921)` or `[title](keg:@namespace/public/921)` +- Treat a bare `keg:` reference in prose as plain text; it does not create a + graph link or backlink - Prefer explicit links over vague references Recommended execution chain: @@ -117,4 +120,4 @@ Lots of unrelated thoughts, no links, no lead, no entity context. ## Reference -- Interlinking best practices: `keg:pub/921` +- Interlinking best practices: `[descriptive title](keg:pub/921)` diff --git a/docs/keg-structure/minimum-node.md b/docs/keg-structure/minimum-node.md index 4f4e65bc..b39f238b 100644 --- a/docs/keg-structure/minimum-node.md +++ b/docs/keg-structure/minimum-node.md @@ -4,36 +4,14 @@ This page documents the bare minimum for a node, plus the recommended practical ## Technical Minimum -A node is recognized by directory name under the keg root: - -```text -// -``` - -Where `` is a valid node id such as `0`, `1`, `2`, etc. - -For filesystem repos, node existence is directory-based. In other words, a directory named as a -valid node id is enough for the repo to treat it as a node. +A node is a Hub-managed aggregate identified by a numeric ID such as `0`, `1`, +or `42`. It exists only after the Hub creates it through `POST /nodes`; creating +a directory or local file never creates a Tapper node. ## Practical Minimum (Required Pattern For These Docs) -For usable, index-friendly notes, create these files: - -- `//README.md` -- `//meta.yaml` -- `//stats.json` - -### Example - -```text -kegs/my-keg/ - 42/ - README.md - meta.yaml - stats.json -``` - -`README.md`: +For usable, index-friendly notes, create the node through Tapper with markdown +content containing an explicit H1 title. For example: ```markdown # Concept: Hydration adjustments @@ -46,7 +24,7 @@ For this documentation pattern, `README.md` should contain: - a title line (`# ...`) - a lead paragraph directly under the title -`meta.yaml`: +Optional metadata can be supplied as YAML frontmatter or through `tap meta`: ```yaml entity: concept @@ -55,27 +33,17 @@ tags: - hydration ``` -`stats.json`: - -```json -{ - "title": "Concept: Hydration adjustments", - "created": "2026-02-26T00:00:00Z", - "updated": "2026-02-26T00:00:00Z" -} -``` +Stats and indexes are owned by the Hub and derived from content, metadata, and +access. Clients do not write them. ## Special Node: Zero Node -Every keg should have node `0` as a stable placeholder/root note. - -Typical file: - -- `/0/README.md` +Every keg has node `0` as a stable placeholder/root note. Leave it unchanged; +write ordinary content in a newly created node. ## Notes -- `meta.yaml` supports manual metadata and tags. -- `stats.json` is the canonical programmatic stats file. -- Empty or missing metadata files are tolerated, but complete files make indexing and migration - significantly easier. +- Metadata supports tags and extension fields. +- Node reads expose content, raw metadata, derived stats, and attachments as one + aggregate. +- The old filesystem layout is not a supported target or migration source. diff --git a/docs/node-snapshots.md b/docs/node-snapshots.md index e4e7c649..dc3b854f 100644 --- a/docs/node-snapshots.md +++ b/docs/node-snapshots.md @@ -31,37 +31,15 @@ tap snapshot restore NODE_ID REV --yes tap snapshot restore 12 1 --keg personal --yes ``` -Overwrites the live node files (README.md, meta.yaml, stats.json) with the -state captured at revision `REV`. A new snapshot is automatically created to -record the restore action. Without `--yes`, the command prompts for +Overwrites the live node aggregate with the state captured at revision `REV`. +A new snapshot is automatically created to record the restore action. Without `--yes`, the command prompts for confirmation on a TTY and refuses in non-interactive contexts. -## Storage Layout - -Snapshots live in a `snapshots/` directory inside the node directory: - -```text -/ - 12/ - README.md - meta.yaml - stats.json - snapshots/ - index.json # Manifest of all revisions - 1.full # Full content at revision 1 - 1.meta # Metadata at revision 1 - 1.stats # Stats at revision 1 - 2.patch # Patch from revision 1 to 2 - 2.meta - 2.stats - 3.full # Checkpoint (full content) - 3.meta - 3.stats -``` - -### index.json +## Storage Model -The manifest is a JSON array of snapshot metadata entries: +Snapshots are durable PostgreSQL records owned by Tapper Hub and addressed by +node plus revision. Clients access them only through the Hub-compatible +snapshot APIs. A revision record has the following logical metadata shape: ```json [ @@ -86,8 +64,8 @@ The manifest is a JSON array of snapshot metadata entries: ] ``` -`Parent` is `0` for the first revision. `IsCheckpoint` marks whether the -revision stores full content or a patch. +`Parent` is `0` for the first revision. `IsCheckpoint` marks whether the Hub +stores full content or a patch internally. ## Patch-Based Compression @@ -100,7 +78,7 @@ full content. The patch algorithm (`line-patch-v1`) uses three operations: | `delete` | Skip N lines from the base | | `insert` | Add new lines | -Patch files (`.patch`) are JSON: +Patch payloads use this JSON shape internally: ```json { @@ -157,8 +135,8 @@ tap archive export -o archive.keg.tar.gz # includes history tap archive export -o archive.keg.tar.gz --no-history # excludes snapshots/ ``` -The archive preserves the full `snapshots/` directory structure so that -imported nodes retain their revision history. +The archive preserves revision history so restored nodes retain their +snapshots, without making the archive layout a live repository format. For full backup and restore workflows, see [Backups And Archives](backups-and-archives.md). @@ -168,9 +146,8 @@ For full backup and restore workflows, see | File | Purpose | |------|---------| | `pkg/keg/keg_snapshots.go` | Keg-level snapshot API | -| `pkg/keg/repository.go` | `RepositorySnapshots` interface | +| `pkg/keg/repository.go` | `RepositorySnapshots` interface used by Hub-side `LocalKeg` orchestration | | `pkg/keg/snapshot_patch.go` | Patch algorithm | -| `pkg/keg/repo_filesystem_snapshots.go` | Filesystem storage | -| `pkg/keg/repo_memory_snapshots.go` | In-memory storage (tests) | +| `internal/testkegrepo/memory_repository.go` | In-memory storage used only by tests | | `pkg/tapper/tap_snapshots.go` | Service layer | | `pkg/cli/cmd_snapshot.go` | CLI commands |